Skip to content

v10.4.0 - enforcement points declare what they can discharge; /health capabilities from one registry

Latest

Choose a tag to compare

@saurabhjain1592 saurabhjain1592 released this 06 Sep 23:39
5f8e934

[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 MaxCustomPolicyConnectorsCommunity first; and the GCS connector now loads credentials_file / credentials_json as 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-154 are 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.
    Deleting TrustStore.Verify's advertised-digest comparison, or any of its
    three provenance comparisons, left TestSignatureBindsTheBytesTheCompilerReads
    and the whole conformance corpus - AXC-009 included - green. Neither is
    reachable by the signature, for two different reasons: Bundle.Digest is
    not inside view()
    , 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 binds PolicyBundleDigest
    into the decision proof, so the one recomputation in Verify was carrying the
    whole property unguarded. bundle_activation_integrity_test.go covers 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). MaxEphemeralSigningLifetime was 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 holding kms:Sign on 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.validateStructure now 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 to not_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). IssuerEpoch was
    written into the signed bytes, populated from the request and validated for
    positivity - and compared against nothing. TrustAnchor carried no epoch,
    CertificateVerifyOptions carried 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 was strings.HasPrefix(cfg.KeyID, "arn:"), which the
    fully-qualified alias ARN satisfies. It caught alias/name and accepted
    arn:aws:kms:...:alias/... - a legitimate ARN that kms:GetPublicKey and
    kms:Sign both 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 than kms: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, resource key/<id>, non-empty region and account), and the id
    kms:GetPublicKey RESOLVED 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 took pep_id from 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/serviceauth gained 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 requires pep_id to
    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.md covers all eight named
    threats with, per control, the attacker capability, a file: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, that TrustStore.Verify
    has no bundle-rollback refusal, and that audit_logs carries no RLS policy. It
    also carries the bound decision-proof field census: three signed objects
    (Binding 33 fields, KeyCertificate 14, KeySetDocument 7), 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 of KeyCertificate'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.
    TestTheKeySetEncodingCoversEveryDocumentField closes 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 in ee only, 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; ee builds and vets clean with
    and without the enterprise tag.

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: twelve Upserts finished before the third Load returned
    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 a Load that 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; the worst: sample now
    prints the direction of a disagreement, which discriminates a doubled epoch
    bump from a stale epoch read.

  • POST /api/v1/access/evaluation declares its 500 and says why its 401
    is not an AuthZENError
    (#3637 item 3). The handler's meet-refusal writes a
    typed AuthZENError with 500, which the spec did not declare, and the 401
    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_MODE normalisation 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.

  • TestBackgroundRefresh stops 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
    backgroundRefresh goroutine 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.go turns the budget table
    in ADR-065-gate17-budgets.md into 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 in Unit Tests: Decision Contracts and 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): under go 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 dedicated gate17-latency-budgets.yml job. 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.Benchmark attempt.

  • The GCS connector loads credentials by type through cloud.google.com/go/auth;
    the two deprecated option.WithCredentials* calls and their //nolint lines
    are gone
    (#3645). google.golang.org/api v0.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
    and credentials_json are documented as service-account keys and are now
    loaded as exactly that (credentials.NewCredentialsFromJSON with
    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 against fake-gcs-server
    (create bucket, put, get, list, delete, health), which Unit 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/evaluation and X-Axonflow-AuthZEN-Profile were 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, AuthZENRoutePath and
    AuthZENProfileHeader are now the one source; the handler derives from them;
    cmd/authzen-codegen publishes them as route and profile_header in
    platform/decision/surface/authzen-surface.json so 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_protocol 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 on axonflow_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: true and 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.
  • The MCP plane reads, validates, binds and counts a capability handshake
    (#3766). The wire contract is enterprise_protocol under 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 on axonflow_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 under outcome="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 on axonflow_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.
  • 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/policy decided the ceiling from a base64
    decode of AXONFLOW_LICENSE_KEY that took the payload's tier on trust: no
    signature check, no expiry, and under the community and community-saas
    postures run.go skips license.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.GetCurrentTier into platform/shared/policy at init
    (platform/agent/license_tier_source.go, policy.SetLicenseTierSource),
    extractTierFromLicenseKey is deleted rather than wrapped, and the read
    itself is one implementation for both build tags (license.ReadCurrentTier
    in platform/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, on axonflow_license_tier_source_unregistered_total and 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, and MCP_DYNAMIC_POLICIES_CONNECTORS is empty on
    every running task, so no running deployment loses a connector.

    • GetCurrentTier in both license.go and tier_support.go delegates to
      ReadCurrentTier; its answer is unchanged (any past ExpiresAt is
      Community, grace window included). Only the refusal became observable.
    • license.SignPayloadForTest and ForgeSignatureForTest join
      OverridePublicKeysForTest as 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.go and
      tier_read_test.go are held identical apart from the ee copy's
      //go:build enterprise line by a second claim class in
      license_pair_byte_identity_test.sh; tier_read_enterprise_test.go is
      held byte-identical.
  • Realm ids follow the decision contract's qualifier grammar, and
    identity.CanonicalFormVersion is identity/2
    (#3709 row 3, filed from
    #3751). identity.ValidateRealmID admitted any printable, colon-free,
    whitespace-free rune while contract's qualifier regex admits only
    [A-Za-z0-9][A-Za-z0-9_.-]*, so an operator who named a realm acme+prod,
    eu/central, realm@okta, -leading or réalm minted principals every
    decision proof bound and the PDP could not parse, reachable through
    realm_verify.go on every credential it admits. Fixed on the permissive
    side: contract.ValidateQualifier is exported, with contract.QualifierGrammar
    as the rule in words, and ValidateRealmID delegates to it, so there is one
    grammar and the refusal names the rule. The realm-charset divergence class
    in the lockstep sweep is retired, User::acme+prod:00u1 joins 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.Upsert now 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, and RealmRegistry.Register refuses 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.Config gains ClientID and ClientVersion, and every engine
    round-trip carries an X-Axonflow-Client header
    (#3668; entry added by
    #3709 row A). platform/shared/pep/pep.go ships in the community build and
    the two fields are set by any PEP built on it: when both are non-empty the
    client sends X-Axonflow-Client: <ClientID>/<ClientVersion> on every call to
    the engine, and an empty ClientID sends no header. Until now the only
    description of this lived in an ### Enterprise sub-bullet about the
    agentgateway PEP adapters, which withheld a capability the community edition
    has. A community-build agent accepts the header and discards it
    (recordClientVersionTelemetry is 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 spelled community
    (#3713, child of #3709).
    resolveConnectorLimitTier in platform/shared/policy/dynamic_evaluator.go
    tested mode != "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 in DEPLOYMENT_MODE was worth a purchased
    Enterprise licence.
    platform/shared/deploymode now 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 to AXONFLOW_LICENSE_KEY, which is how the
    free Evaluation tier is actually granted. Note precisely what that does and
    does not do for evaluation: it makes the ceiling REACHABLE, it does not
    grant it by name - that mode with no licence key resolves to community (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
    neighbouring AppliesCategory,
    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.
    Substituting AppliesEnterpriseSchema() 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 over canonicalModes (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.
    ValidateCustomPolicyConnectorLimit and EnforceCustomPolicyConnectorLimit
    each carried a switch recognising only enterprise, so professional and
    plus - the lower-cased forms of license.TierProfessional and
    license.TierEnterprisePlus - fell into the COMMUNITY default of 2, while the
    unreferenced CustomPolicyConnectorLimitForTier read both as unlimited and
    license.GetTierLimits, the authority in both build tags, maps
    TierProfessional, TierEnterprise, TierEnterprisePlus alike onto
    EnterpriseLimits (CustomPolicyConnectors: -1). This was latent while only
    community and unset reached the licence-key path; routing community-saas,
    evaluation and unrecognised modes onto it ARMED it, and a paying Professional
    deployment whose DEPLOYMENT_MODE carried a capital letter would have gone
    from unlimited to 2 connectors, truncated SILENTLY. All three now resolve
    through one customPolicyConnectorLimitForTier; 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 - EnabledConnectors has exactly one
    writer, NewDynamicPolicyEvaluatorFromEnv reading
    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 through secrets or
    environmentFiles either, so len(EnabledConnectors) > limit is 0 > 2 and
    no tier can truncate anything. Independently, the community-SaaS agent carries
    a licence key whose payload names tier Enterprise, so the one deployment
    this change moves off the mode-based answer lands back on enterprise through
    the licence key. The change therefore revokes nothing that is running. The
    two evaluation-mode surfaces outside the fleet were measured too, after R3
    raised them - scripts/setup-e2e-testing.sh and
    .github/workflows/tier-gate-contract.yml - and neither sets
    MCP_DYNAMIC_POLICIES_CONNECTORS at 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: extractTierFromLicenseKey takes the payload's
    tier on trust with no signature and no expiry check, and
    platform/agent/run.go skips license.ValidateWithRetry entirely under both
    the community and community-saas postures, so a key signed with the literal
    bytes NOTASIG! naming tier Enterprise resolves to enterprise there. 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.GetCurrentTier validates the
    signature AND compares ExpiresAt - and platform/shared cannot reach it
    without importing platform/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 literal os.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, so dynamic_evaluator.go comes 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
    evaluation the licence path one frame later than the original defect left
    every test and the lint GREEN, because the only evaluation row carried no
    licence key, where community is 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.yml and
    scripts/e2e/runtime_e2e_suites.tsv (repo tooling; /scripts/* is stripped
    while scripts/lint-deployment-mode.sh is 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:local is
    emitted and parsed by identity.PrincipalID and, separately, by
    contract.ID in the decision module, with the same algorithm and a different
    vocabulary: identity.SubjectType is a CLOSED set of six and contract.ID's
    type segment is an OPEN regex, so Robot::okta-prod:00u1 is 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/decision is a separate module with a deliberately
      minimal dependency set and axonflow/platform depends on it, so the
      vocabulary cannot be shared as code without inverting that. Instead
      contract.ID.Type now states in its own source that it is deliberately
      open, names identity.SubjectType as the closed vocabulary and
      identity.CanonicalFormVersion as 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 - ValidateRealmID is deliberately permissive about
      printable characters while contract's qualifier regex is not, so an
      operator naming a realm acme+prod mints 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
      gives contract its 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 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
    Target had no server member while DecisionTarget in
    docs/api/agent-api.yaml declared one and decision_handler.go fed it to
    capability-scoped evaluation and to tool_server on 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.yaml declared 6 properties on
    PolicyEvaluationResult against a Go type carrying 17. Among the eleven
    missing was evaluation_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.

    • TestThePublishedSchemasMatchTheTypesThePlatformMarshals walks 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, so pep.Target is compared against DecisionTarget
      • 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.
    • BOTH MIRRORS of each Decision API DTO are anchored on the same schema, so
      platform/agent and platform/shared/pep agree with each other by
      construction. That is what found the third gap the audit missed: a
      DecideResponse.Error member on the PEP mirror that no server has ever
      populated, unreachable because Decide decodes only 200 bodies into that
      type and the 200 body has no such field. It is removed: a PEP author
      writing if resp.Error != "" got a branch that could never be true while
      every real failure came back through the error return.
    • The server member 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 $ref that does not resolve, a oneOf at
      either level, the tuple form of items, a node declaring both items and
      properties, and a cyclic allOf or items chain - each naming the cause
      it OBSERVED. Two hostile rounds are why: the first version passed silently
      on a document declaring applied_policies as 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-adapter and
      .../decision-mode-mcp-adapter. The first carried the same phantom error
      member and it is removed there too; the second already diverges, carrying
      expires_at as 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 /health planes
    advertise
    (#3712). getSDKCompatibility() was a hand-maintained map literal
    in platform/agent/capabilities.go AND in platform/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 sat getPluginCompatibility(), which had
    already been consolidated onto platform/shared/plugincompat for exactly this
    reason after it drifted one-sided in a real train - the SDK maps were left
    behind by that fix. Both planes now return platform/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 instrument plugincompat'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 a const
    identifier was not resolved. All three are how a person would actually write
    it - every SDK release tag is vX.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
    live checkpoint-service map 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.yaml advertised 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 omitted rust from its schema description although rust has
    been in the map since the 9.7.0 train; docs/api/orchestrator-api.yaml gave 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: both example: blocks in both specs are
    now compared against sdkcompat by a test, and a stale example fails CI. The
    third is docs/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: /health serves the same bytes.

  • An external enforcement point can finally say what it is and what it can
    discharge
    (#3704, ADR-065). platform/decision/registry has been the
    ADR-065 PEP catalog since it was written - PEPRecord,
    SupportsObligation, and a CapabilityStatus that 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 through X-Axonflow-Client, which carries a library
    name and no capabilities, so every external enforcement point was NoRecord
    for ever and the only reason nothing refused was that nothing asked.
    The gateway adapters can present one, opt-in behind AXONFLOW_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 presents X-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_capabilities can now be SENT as an empty list, and its
    meaning is deliberately unchanged
    (#3704, on top of #2958). The member was
    []string with omitempty, 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 is log, 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 returned nil for an empty input, so a registered
    enforcement point that declares it discharges nothing rendered
    "capabilities": null from PEPRecord.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.yaml documents the handshake header and its document, and
    documents fulfillment_capabilities for 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). WriteHITLAuditEvent wrote an
    audit_logs row with fifteen columns and none of them was decision_id,
    plane or correlation_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/119 documents decision_id as 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 as correlation_id - which core/121
      defines as precisely "a value shared by every decision row of one logical
      request" - plus an explicit policy_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_id is 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.plane is hitl on these rows - its own plane, because plane
      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 NULL response_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 ask queryDecisionList - the
      function GET /api/v1/decisions calls - 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.
  • 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.md and the handler itself all describe
    POST .../alerts/{id}/acknowledge and /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); the PUT registration
      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. AcknowledgeAlert and
      ResolveAlert took (alertID, userID) and never saw an org, and
      GetAlertByID is WHERE 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.md are
      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.
  • 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 in platform/: the audit non-repudiation signing
    key in platform/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, in ee/, 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.GetBase64Seed is now the one entry point THOSE THREE use - trim,
    then standard, raw-standard, URL-safe or raw-URL-safe, first success wins -
    with secretenv.DecodeBase64Tolerant for 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 for RawStdEncoding and
    so could only find decoders that were already tolerant - leaves
    getPluginClaimPublicKey in both licence copies (#3727) and
    NewCredentialEncryptorFromKey in platform/connectors/config still 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
    with os.Getenv in both copies of platform/agent/hitl/webhook.go and
    handed straight to hmac.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. ErrNotSet keeps "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 reports expected 32 bytes, got 0.
    What a strict decoder actually rejected, measured rather than assumed:
    an UNPADDED value (illegal base64 data at input byte 40 for 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 \r and \n at every position, leading,
    interior and trailing - and #3710's premise that it was is corrected here,
    in the secretenv doc 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-modules discovers every go.mod under
    platform/ and scripts/, but detect-changes' go-code filter listed only
    platform/**/*.go and ee/platform/**/*.go. The lane still fired on this
    change, via **/go.mod and .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/**/*.go is added to that filter in test.yml and
    test-community.yml, verified against real picomatch (the matcher
    dorny/paths-filter uses) in both directions. A census of every
    dorny/paths-filter filter in all ten workflows that carry one found no third
    instance: lint.yml already matched via **.go, and security.yml's
    manifest-only scope is deliberate.
    Discovery also now excludes testdata/ and vendor/ and refuses a module
    path containing whitespace: the Go toolchain ignores testdata by
    construction and this find did not, so a fixture module under one of
    platform/'s three existing testdata trees would have reddened a step that
    gates the required Test Summary on every pull request.
    And the license package now runs in an ordinary lane. All eighteen files
    in ee/platform/agent/license are //go:build enterprise, so
    unit-tests-enterprise's untagged go test ./... matched no package there
    and exited 0 - a lane that compiled nothing, reading as a pass. The only
    tagged runs were in the ci: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.sh enforces
    the byte-identity the two keygen_seed_paste_test.go copies 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. New platform/decision/replay package
    and platform/decision/cmd/decision-replay command, 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 of axonflow/platform.

  • One capability registry, and a /health list 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
    , including POST /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 as testdata/health_wire_freeze_*.json; a test
    compares the projection against them name, since and 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 matching since values. 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/ast parses every non-test Go file under the
    declared scan roots and resolves the path argument of every HandleFunc /
    Handle / PathPrefix / Path call, following package-local and
    cross-package string constants and PathPrefix(...).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 them POST /api/v1/decide and
    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 /health is now a RECORDED decision: every entry
    carries either a health block or a health_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 route authzen_evaluation names 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 enforce is 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=enforce began 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. enforce is 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 "the none child 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_PATHS narrows which legacy credential paths
    evaluate, mirroring AXONFLOW_DECISION_SHADOW_PLANES on the decision axis.
    The failure this exists for is one path going wrong for everyone - a fleet
    asserting only an email on trusted_header, an IdP whose JWKS endpoint starts
    timing out on oidc - 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 an off mode 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 than AXONFLOW_IDENTITY_COMPAT_MODE,
    which accepts false, 0 and disabled as spellings of off - 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 in docker-compose.yml,
    docker-compose.enterprise.yml and docker-compose.scaled.yml, and as the
    IdentityCompatPaths CloudFormation parameter on both the community-SaaS and
    marketplace templates, wired through update-stack.yml including 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
    AllowedPattern enumerates 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.EnvCompatConfig is now the single os.Getenv site for
    AXONFLOW_IDENTITY_COMPAT_MODE, _ENFORCE_REASONS and _PATHS anywhere
    under platform/, 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). TestTemplatesMatchAgentAPISpec holds the
    registered path templates against docs/api/agent-api.yaml in both
    directions, and both the locate and the read were t.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.mod in this repository references path_template, and its only importer
    is platform/agent in 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_id from string to integer in the schema and
    regenerating the artifact left RequestID string in Go, and all nine
    platform/decision packages 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.SeparationOfDuties is
    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 as false:
    "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 the delivery
    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 means false: 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.Parse checked 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 satisfied required on the way past. The
    measured consequence, on the member that matters: a policy document whose
    obligation omitted mandatory was 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 nested minLength, enum and minimum
    constraints the re-render cannot satisfy; mandatory is 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.mandatory and approval_requirement.separation_of_duties have
    been declared required by contract-2026-08-29.schema.json since it was
    written, and the Go decoder could not see either: encoding/json cannot tell
    an omitted member from false, and DisallowUnknownFields only 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 own Validate with a typed error
    naming the member's JSON Pointer (/obligations/1/mandatory), so the
    combining sites keep a plain bool and gain no nil checks. A member present
    as null is 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/json silently 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.Parse is 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 a separation_of_duties parameter,
    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). ComposeObligations split the set on
    Mandatory BEFORE 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 reason schema_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 and attribute.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 as false - 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 set approval - so every response
    POST /api/v1/access/evaluation has returned since v10.3.0 omitted the
    approval challenge that both the authzen_evaluation capability entry and
    docs/api/agent-api.yaml promised
    . A PEP that read the capability list,
    negotiated the profile specifically to receive the challenge and got a context
    with no approval member had no error and no signal: an absent approval is
    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 as state: CHALLENGE with decision: 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
    approval member stays DECLARED in docs/api/agent-api.yaml with its
    $ref unchanged, and the RESERVED note sits on the parent schema's
    description - a description beside a $ref is ignored under OpenAPI 3.0.3,
    and wrapping the $ref in an allOf to 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 as state == 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 -
    decision is false either 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_approval verdict 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 signal DEPLOYMENT_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 own DEPLOYMENT_MODE, canonical
    spelling, aliases folded so enterprise and in-vpc-enterprise are one
    population rather than two rows). The pre-existing deployment_mode field is
    UNCHANGED: it stays the coarse topology (self_hosted | community_saas),
    so no dashboard or classifier rule moves.

    • /health gains the same two members on the agent AND the orchestrator,
      beside tier and version, 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_discovery capability. A member the platform cannot
      determine is omitted, never emitted empty: an unset DEPLOYMENT_MODE
      drops the key rather than reporting the community schema 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 their axonflow- org_id. Suppressing at the emitter
      destroyed the datum; classifying at the receiver keeps it and labels it.
    • One emitter, not three. platform/shared/heartbeat is 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 missing org_id survived. The CI auto-suppress, the 7-day per-binary
      rate limit, the stamp-on-delivery contract, the AXONFLOW_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.md and docs/TELEMETRY_CONTRACT.md are brought current.
      They were materially stale: "All 4 SDKs" (there are five), a
      deployment_mode example 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's license_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 into unknown ("reports the
      dimension, had not resolved it"), which would have changed a shipped
      emitter's field while this line claimed nothing did.
    • heartbeat.Config gains an OrgID field, and the environment fallback
      is unchanged: a caller that leaves it empty still reads ORG_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 no org_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 than ORG_ID, set Config.OrgID
      ; absence is still reported as
      absent and is never defaulted.
  • core/170 makes 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 fresh in-vpc-enterprise chain by reading
    has_table_privilege rather than reading the migration sources: 125 tables,
    26 under FORCE ROW LEVEL SECURITY, and zero lacking
    axonflow_app_role's SELECT or INSERT - core/098 grants ON ALL TABLES for
    everything present when it runs and ALTER DEFAULT PRIVILEGES for 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/098 declares 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 included role_assignments, which the
    live chain does not force, and omitted compliance_report_jobs and
    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_violations and customers are created by
    Enterprise migrations but forced by core/107 and core/108, files the
    community mirror carries - and the census that reads those files runs in that
    tree too, where migrations/enterprise does not exist, so a grant written in
    the Enterprise sibling could never satisfy it. to_regclass makes 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.
    A GRANT is not reference-counted - core/098's ON ALL TABLES and the
    backfill's per-table grant are the same privilege - so a REVOKE on rollback
    would remove the access core/098 created rather than the access the
    migration added, leaving the application role unable to read organizations,
    tenants, connectors and 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: 64 on the bulk envelope's
    evaluations array, in the canonical JSON Schema (artifact regenerated)
    and in docs/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's maxItems and 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-side max_items validation, 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 from ee/platform/agent/gateway_adapters to
    platform/gateway-adapters, and from the axonflow/ee Go module into the
    root module — so the import path is axonflow/platform/gateway-adapters and
    no ee replace 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.Edition is now edition.Current rather 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 report edition=enterprise on 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-adapters rather than platform/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.
  • The HITL approval-queue chokepoint guarded creation and left every state
    transition unguarded
    (#3714, pre-cut audit #3709). #3408 made the count of
    authored INSERT INTO hitl_approval_queue exactly 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.go said SIX authored
      UPDATE hitl_approval_queue and named five; there were EIGHT - Override,
      ExpireStaleReturning and ConsumeGrant duplicated across the
      platform/ee/ twin pair, plus UpdateStatusSQL and the orchestrator's
      expireEvalApprovals. The comment was written before ConsumeGrant arrived
      (#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
      into platform/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 FROM and TRUNCATE against
      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, an UPDATE hitl_approval_history,
      hitl_approval_queue_archive and UPDATE other_table FROM hitl_approval_queue are 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
      of expire_hitl_requests() in migrations/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 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-tenant UPDATE on whatever pool it was handed; under
    AXONFLOW_DB_USE_APP_ROLE=true that is axonflow_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_role the 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 falsely pending since 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 FULL when 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.Service fails OPEN on a nil tier
      provider while queue.Enqueuer fails CLOSED) and item 3 (/hitl/stats and
      hitl_pending_summary counting wcp_step_gate mirrors, which needs a
      migration) are untouched.
  • A trap on 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
    being scripts/operators/provision-app-role.sh - an operator script that
    provisions a database role. Measured with a reduced case:
    trap cleanup EXIT INT TERM exits 0 with the body CONTINUING after SIGINT;
    trap cleanup EXIT plus trap 'cleanup; exit 130' INT and
    trap 'cleanup; exit 143' TERM exits 130 and never reaches the body.

    • New scripts/lint-trap-handlers-exit.sh flags the shape, distinguishing a
      handler that exits from one that does not rather than matching trap at all;
      it resolves a bare handler name to that function's own body, so a sibling
      function's exit cannot 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
      success by Lint 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,
      and lint.yml itself syncs - so a lint job whose script is not re-included
      calls a 404 on the public repo and reddens Lint 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 under scripts/, runtime-e2e/ and
      .gitattributes, exactly FOUR reach the mirror (provision-app-role.sh via
      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.
  • 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 on scripts/e2e/runtime_e2e_suites.tsv by construction, and a
    conflicted PR is DIRTY - 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".
    .gitattributes now declares that file merge=union, which removes the class
    permanently for the one file whose correct resolution is always "keep both
    rows". Deliberately NOT applied to CHANGELOG.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 -f4 returns 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. .gitattributes reaches the community mirror; the
      ledger and its linter do not (/scripts/* and runtime-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.
  • 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/deploymode held the SCHEMA half of what DEPLOYMENT_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.go rather than in either helper.
    deploymode now exports IsCommunityPosture / IsCommunitySaasPosture and
    their Current* 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,
    evaluation and community-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.go computes
    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
    resolveConnectorLimitTier reads mode != "community" && mode != "", putting
    UNSET on the community side - the opposite of #3096 - and evaluation and
    community-saas on 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.sh greps for the literal
    os.Getenv("DEPLOYMENT_MODE"), and three live escapes from it are on record:
    corspolicy spelled the read through a local constant and was never on the
    allow-list at all; a value from deploymode.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.
    TestTheCommunityPostureIsDecidedInExactlyOnePlace instead walks the tree for
    the shape that is actually wrong - a DEPLOYMENT_MODE value meeting a mode
    name - through assignments, through strings.ToLower/TrimSpace, and across
    function boundaries, with the set of mode names derived from
    RecognisedModes() rather than listed. Measured: planting a fresh reading
    spelled deploymode.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 read DEPLOYMENT_MODE once, 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.sh itself 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.
    corspolicy is 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 tagless switch case), 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 in platform/orchestrator/llm/bootstrap.go turned 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 with Target.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_server and .tool_name
    were written empty, and a HITL queue entry showed a human approver the literal
    string mcp_tool where 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_query blocked,
    editJiraIssue allowed. 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 being server/tool by 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 no Server field at
    all, while its platform-side mirror DecisionTarget has 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 from service_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 in pep.TargetTypes and constructed from rather than spelled at
    each site; a repo-wide guard fails on any Target.Type string 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.yaml states what tool costs to get wrong, since its
    type description 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.