Skip to content

feat(typed-policies): v11 parity: typed members, active(), v11 legs - #264

Merged
saurabhjain1592 merged 4 commits into
mainfrom
feat/3746-d6-v11-parity
Sep 14, 2026
Merged

saurabhjain1592 merged 4 commits into
mainfrom
feat/3746-d6-v11-parity

Conversation

@saurabhjain1592

@saurabhjain1592 saurabhjain1592 commented Sep 14, 2026 •

Copy link
Copy Markdown
Member

Summary

v11 parity for the v11.0.0 SDK train (#262): the typed policy answers carry every member a v11.0.0 platform sends, active() keys on the platform's reason, the v11 examples say what activation does and run as a live leg, and the rest of #260 lands with it. The Go SDK carries the same shape (getaxonflow/axonflow-sdk-go#239), and so does the Rust SDK (getaxonflow/axonflow-sdk-rust#100 and #101); the cross-SDK rows are on getaxonflow/axonflow-enterprise#4249.

  • The typed answers (axonflow/typed_policies.py, axonflow/exceptions.py):
    • TypedPolicyRefusal.policy, the policy a tier refusal names (keyword-only, like the refusal's other members);
    • TypedAuthoringEdition.catalog_digest, registry_version and catalog_fixture, the vocabulary's identity;
    • TemplateOmissionReport(omitted, of, message), as template_omissions and template_omissions_unavailable on both the publication and the activation (the platform's shape, platform/decision/activation/restriction.go:217-221), beside the activation record rather than inside it;
    • TypedPolicySystemControl.name.
    • TypedPolicySystemControl.mandatory is now a plain bool, False when the platform omits it or sends null, where it was bool | None. Typed authoring is unreleased ([Unreleased] in the CHANGELOG), so no released caller sees the type change.
    • TemplateOmissionReport is exported from axonflow and axonflow.typed_policies.
      The platform's spec does not declare these members yet: getaxonflow/axonflow-enterprise#4262.
  • active() returns None only for a 404 whose reason is nothing_active. Any other 404, from a platform before v11.0.0 or an endpoint that is not an agent, raises TypedPolicyRefusal with status 404. The docs cite getaxonflow/axonflow-enterprise#4255 (the platform also answers nothing_active for a store read failure). No SDK release has shipped typed policies, so this is not a change to released behaviour. D6: v11 parity (v11.0.0 SDK train) #262 item 2 asked the docs to say "An SDK release from before this change read any 404 as nothing active." That sentence is not carried: no release could have done so, and the release owner ruled it out for every SDK, with the cross-SDK evidence comment and the other SDKs' parity issues amended to match.
  • The examples:
    • examples/typed_policies.py finds its default document from its own location, so it runs from any directory. It prints the publication's template-omission report before it activates, and a refused publication or activation it asked for fails the run, printing the platform's reason for either;
    • examples/pep_handshake.py carries the #4247 follow-on after its unknown_constraint sentence, and says the engine refuses on every edition;
    • the README runs the handshake example first, with the unknown_constraint sentence and its follow-on.
  • The README's typed section gains "Activation replaces the organization template" (the publish fixture omits every template control), the refusal's policy, and the active() bullet.

Closes #260
Closes #261
Closes #262
Part of getaxonflow/axonflow-enterprise#3746

#260, item by item

Each item below is #260's body or one of its comments.

  • The v11 examples run as a live leg (the issue's body). runtime-e2e/v11_examples/run.sh copies the package sources to a temporary directory, installs the SDK from there into a virtual environment (so nothing is built inside the tree), checks that the examples' interpreter imports it from that environment, and runs both examples with it, from that directory, with no PYTHONPATH, on a live Community stack, in the README's order. It replaces the [skip-runtime-e2e] hatch the examples shipped with. See Live legs.
  • The simulation methods' deprecation wording (the first comment):
    • "the typed simulate on /api/v1/typed-policies, which ships with the v11 series" is gone from simulate_policies on both clients: no such route exists.
    • All six docstrings (simulate_policies, get_policy_impact_report and detect_policy_conflicts, on the asynchronous and the synchronous client), the README and the CHANGELOG say each method keeps answering until v11.1, and that on a v11.0.0 platform its result comes from the legacy engine, which no longer decides, so it does not predict what the platform enforces. They point at the typed policy methods.
  • Route deprecation is recorded once per route, not once per id (the first comment):
    • The record keyed on the concrete path, so /api/v1/static-policies/{id}, its /versions and /override, and /api/v1/dynamic-policies/{id} were reported once per id.
    • The eleven id-bearing call sites now name their route template and pass the id as path_id; _request and _orchestrator_request build the path from the template, so the template and the path cannot disagree, and _warn_if_route_deprecated keys on the template. The Java SDK's route-deprecation record is the model. The id is still sent as given, not escaped: a pre-existing gap, filed as Id-bearing policy paths are not escaped #263.
    • Three tests cover it:
      • two ids on the static and the override route give one report each;
      • the eleven methods, each called with two ids, give exactly the ten template keys, and the server receives each call's own request, method and path;
      • a source census over the platform's seven deprecated families (static-, system-, dynamic- and tenant-policies, policies, templates, policy-overrides) finds no path built with a value after the family, whether an f-string interpolation, a concatenation, %-formatting or str.format, with a planted case of each per family, and pins the eleven template sites.
    • test_a_refused_stamped_request_is_still_reported_once changes with it, the intended behaviour change: it expected the per-id key GET /api/v1/static-policies/pol_missing, and expects the template GET /api/v1/static-policies/{id}.
  • The Community-credentials paragraph (the first comment): nothing to change, as the comment measured; the examples default AXONFLOW_CLIENT_ID to community.
  • The engine-framing docs row (the second comment): axonflow/pep_handshake.py and runtime-e2e/pep_handshake_planes/README.md no longer say a Community deployment records the declaration and does not deny, and the README's handshake bullet ("Which edition refuses what") and examples/pep_handshake.py say the same. On every edition the engine refuses a mandatory obligation the declaration cannot discharge (composeSet); what only Enterprise adds happens at the handler.

#261: after every copy of the unknown_constraint sentence (examples/pep_handshake.py, examples/typed_policies.py, the README, runtime-e2e/v11_examples): "From v11.0.0 the deny's first reason is that code, followed by one naming each constraint it could not evaluate and the attribute it needed (getaxonflow/axonflow-enterprise#4247)."

Tests

New tests, one per guard, each proven to run by name (pytest -rA, PASSED <node id>, in the mutant run's baseline below):

  • tests/test_typed_policies.py:
    • test_a_404_that_is_not_nothing_active_is_a_typed_refusal (a plain-text 404 and a 404 with another reason)
    • test_the_edition_names_its_vocabulary
    • test_the_publication_carries_the_template_omission_report
    • test_a_publication_whose_omission_report_is_unavailable_says_why
    • test_the_activation_carries_the_omission_report_beside_the_record
    • test_an_activation_whose_omission_report_is_unavailable_says_why
    • test_a_system_control_names_itself_and_is_not_mandatory_when_unsaid (absent and null both read False, compared by value so a drift back to None fails)
    • TestTheExample::test_the_example_finds_its_default_body_from_its_own_location
  • tests/test_legacy_deprecations.py, TestOncePerTemplate:
    • test_a_stamped_route_with_an_id_is_reported_once_not_once_per_id
    • test_every_id_bearing_method_reports_its_template_once
    • test_no_id_bearing_path_is_built_outside_its_template

Changed: the refusal table's tier row carries policy, and every row asserts refusal.policy (the outage row names none); the null table gains "template_omissions": null and "omitted": null; test_a_refused_stamped_request_is_still_reported_once, above.

Local round

The head, 7398589, differs from 1595df3 only in the wording of its last commit's message; the two trees are identical (0f88682f6300), so every result in this description, run at 1595df3, holds for the head.

Every step at the head 1595df3, each under its own build-slot ticket, in a Python 3.11 virtual environment with the SDK installed editable from this tree (checked before the round), mirroring the required contexts. Before each step the round checks that the head is unchanged and the tree clean.

Step Command rc
lint ruff check . and ruff format --check . 0
types mypy axonflow (strict) 0
falsey-clobber scripts/lint_no_falsey_clobber.py axonflow/ --baseline .lint_baselines/falsey_clobber.json 0
AuthZEN types scripts/gen_authzen_types.py --check 0
test pytest, with the configured --cov-fail-under=75: 85.80% 0
no mocks scripts/lint-no-mocks-in-runtime-e2e.sh 0
wire shape pytest tests/test_wire_shape.py -m wire_shape against tests/fixtures/openapi (unchanged; no baseline move) 0
examples py_compile and import of both examples 0

The falsey-clobber baseline moves 15 entries, and adds none. .lint_baselines/falsey_clobber.json keys each pre-existing finding by file, line and column. The route-template helper and docstrings added to axonflow/client.py moved 15 of those findings down the file, so the lint reported them as 15 new findings and 15 stale entries. Before regenerating, each of the 15 was checked: its source text is identical to main's text at the stale line number, and matches the line it is now reported at. The only lines this change adds under axonflow/ that contain or are docstring prose. The baseline was rebuilt by the script's own --write-baseline: the diff is exactly 15 client.py entries moved, and the check passes with 51 findings, all baselined, the same count as before.

Mutants

Each mutant is an exact-once string replacement in the source, restored from saved bytes after every mutant (never git checkout). It counts as KILLED only when its named test node ran and failed (pytest -rA, FAILED <node id>); a mutant that does not import or collect would score INVALID, and a named node that did not run would score ERROR. The unmutated baseline must pass each named node by name first: 14 nodes passed. Before running, the harness prints the token each mutant changes, so a mutant named for one token cannot quietly move another. 24 of 24 KILLED at 1595df3.

A harness note. The first run stopped at its baseline on a bug in the harness, not the tests. The pattern that finds a node's PASSED line ended in a word boundary, \b, which never matches after a parameter id's closing ], so every bracketed node read as not passed; the test's own log showed it passing. The pattern now ends at whitespace or the end of the line. Before the run above, it was checked against that log and three planted cases: a node that is a prefix of a longer name does not match the longer one; a node without a parameter id matches each of its cases; a bracketed node matches only its own case.

Mutant Change Killed by
active-any-404-is-none any 404 reads as nothing active test_a_404_that_is_not_nothing_active_is_a_typed_refusal (both cases)
active-wrong-reason the reason compared is no_active_policy test_nothing_active_is_none
reason-helper-reads-nothing _reason() returns None test_nothing_active_is_none
refusal-policy-dropped _raise_for_refusal stops passing policy test_every_documented_refusal_is_typed[publish-402-tier-limit]
refusal-policy-not-kept TypedPolicyRefusal stores None for policy test_every_documented_refusal_is_typed[publish-402-tier-limit]
edition-digest-field catalog_digest -> catalog_digests test_the_edition_names_its_vocabulary
edition-registry-field registry_version -> registry_versions test_the_edition_names_its_vocabulary
edition-fixture-field catalog_fixture -> catalog_fixtures test_the_edition_names_its_vocabulary
publication-omissions-field the publication's template_omissions -> template_omission test_the_publication_carries_the_template_omission_report
publication-unavailable-field the publication's template_omissions_unavailable -> template_omission_unavailable test_a_publication_whose_omission_report_is_unavailable_says_why
activation-omissions-field the activation's template_omissions -> template_omission test_the_activation_carries_the_omission_report_beside_the_record
activation-unavailable-field the activation's template_omissions_unavailable -> template_omission_unavailable test_an_activation_whose_omission_report_is_unavailable_says_why
report-omitted-null-not-empty omitted no longer reads null as empty test_a_nil_go_collection_reads_as_empty[publish-template-omissions-omitted]
control-name-field name -> names test_a_system_control_names_itself_and_is_not_mandatory_when_unsaid
mandatory-back-to-optional mandatory: bool | None = None again test_a_system_control_names_itself_and_is_not_mandatory_when_unsaid
mandatory-null-not-false null is no longer read as False test_a_system_control_names_itself_and_is_not_mandatory_when_unsaid
route-key-ignores-template the record keys on the concrete path test_a_stamped_route_with_an_id_is_reported_once_not_once_per_id
agent-request-drops-template _request fills the path but forgets the template test_a_stamped_route_with_an_id_is_reported_once_not_once_per_id
orchestrator-request-drops-template _orchestrator_request fills the path but forgets the template test_every_id_bearing_method_reports_its_template_once
route-with-id-ignores-id _route_with_id drops the id test_every_id_bearing_method_reports_its_template_once
versions-template-wrong the {id}/versions template loses /versions test_every_id_bearing_method_reports_its_template_once
toggle-dynamic-sends-patch toggle_dynamic_policy sends PATCH test_every_id_bearing_method_reports_its_template_once
a-call-site-untemplated one DELETE call site goes back to an f-string path test_no_id_bearing_path_is_built_outside_its_template
example-default-body-moved the example's default body path loses fixtures/ test_the_example_finds_its_default_body_from_its_own_location

Live legs

Both legs ran at the head 1595df3, each on its own fresh Community stack. The stacks were built from axonflow-enterprise 857455033, with the agent and orchestrator on the application database role (the boot probe passed 3 of 3, with every pool on axonflow_app_role). Neither used an enterprise licence or secret. Each stack was torn down by its compose project label, leaving 0 containers and 0 volumes by label.

runtime-e2e/typed_policies: PASS, 23 assertions, 0 failed. What it read live:

  • active() is None before anything is activated: the platform's 404 nothing_active.
  • The edition names its vocabulary catalog_digest=sha256:8eec9ebc…, registry_version=2, catalog_fixture=False.
  • system() returns 118 controls: 118 named, 25 mandatory, and every mandatory a bool.
  • The publication reports template omissions: 22 of 22, and the activation reports the same omissions.
  • The document in force is the one just activated, its source is the signed source, and the platform signed the author as Client / axonflow-api-credential / runtime-e2e, not the name in the request.
  • Re-activating the active digest: 409 activation_refused ("refusing to promote version 1 over active version 1").
  • Publishing with no fixtures: 422 publication_refused.
  • An unregistered action: a rejecting ACTION_NOT_REGISTERED finding on validate, and 422 document_refused carrying it on publish.

runtime-e2e/v11_examples: PASS, 13 assertions, 0 failed, after the curl precondition answered 404 nothing_active. The SDK was installed from a copy of this tree's sources into a virtual environment in a temporary directory, and the check of where the examples import it from found that environment (…/venv/lib/python3.11/site-packages/axonflow). Every example ran from that directory, with no body file and no PYTHONPATH:

  • Run 1, pep_handshake.py: exit 0. The first decide was verdict=allow, and the declaration the platform would refuse failed in the client, at /pep_id, before anything was sent.
  • Run 2, typed_policies.py without publishing: exit 0, nothing is active.
  • Run 3, with AXONFLOW_TYPED_POLICY_PUBLISH=1: exit 0. It printed template omissions: 22 of 22 template controls: ... before activated.
  • Run 4, a document with an unregistered action: exit 1, refused: HTTP 422 document_refused: ....
  • Run 5, the same document published again: published sha256:c6c7cef2... (version 1), then activation refused: HTTP 409 activation_refused: authoring: refusing to promote version 1 over active version 1; ..., exit 1.
  • Run 6, observed, not asserted: verdict=deny obligations=0 reasons=['unknown_constraint']. The stack is built from 857455033, which predates getaxonflow/axonflow-enterprise#4247, so the deny carries only the bare code.

R3

Two rounds by a read-only reviewer that built and ran nothing, each checked against the platform source (axonflow-enterprise at 857455033) and against the Go SDK's merged equivalent (getaxonflow/axonflow-sdk-go#239).

Round 1, at e47563a: NOT-SHIP-READY, with no code defect. Folded at 760a7d2:

  • The docs said "An SDK release from before this change read any 404 as nothing active", which D6: v11 parity (v11.0.0 SDK train) #262 asked for. No SDK release has shipped typed policies, so the release owner ruled the sentence out for every SDK. It is dropped here (see the summary), and the Go and Rust SDKs drop it in their own follow-ups.
  • The leg's check of where the SDK imports from could report the tree rather than the virtual environment. It now asks from the temporary directory with PYTHONPATH unset, compares real paths, and fails unless the SDK comes from the virtual environment; every example runs with no PYTHONPATH.
  • The leg did not turn telemetry off. It does.
  • pip install of the tree built inside it. The leg now installs from a copy of the package sources in the temporary directory.
  • Two issue references would have linked to this repository; both now name getaxonflow/axonflow-enterprise in full.
  • registry_version was printed, not asserted, in the typed leg. It is asserted.

Not folded, by ruling: #261 also suggested pointing callers at the deny's second reason and first evaluated policy. The ordering is the platform's documented contract, not something this SDK's docs should promise again, so #261 closes on the #4247 sentence alone.

Round 2, at 760a7d2: SHIP-READY. It verified each fold by file and line. It confirmed that registry_version > 0 matches the platform (a deployment vocabulary carries 2, a test fixture 0), and that the copied sources build the package (a static version, no setup.cfg or MANIFEST.in). Of its seven low items, six were folded at bdbb691:

  • the CHANGELOG described typed authoring's members and the examples' behaviour in a separate entry that read as a change to shipped code; they now sit in the entries for the feature and the examples;
  • this description records why D6: v11 parity (v11.0.0 SDK train) #262 item 2's sentence is not carried;
  • a virtual environment that cannot be built exits 2 (environment), not 1;
  • the install check imports with the examples' directory first on sys.path, as the examples do;
  • PYTHONPATH is unset once for the whole script;
  • an import failure says so.

Not folded: process wording in one commit message, which this repository's squash merge does not carry.

CI notes

All four required contexts are green by name at 7398589: lint (run 34810048319), Lint — no mocks in runtime-e2e/ (run 34810048184), Runtime E2E required for user-facing changes (run 34810048184) and Test Summary (run 34810048319). Every other check at the head passed (CodeQL, DCO, build, test (3.11), Contract Tests (Python 3.11), Demo Scripts Validation, Validate Version Alignment, wire-shape-contract, real-stack ubuntu-latest), and Integration Tests (Community Stack) reported skipped.

The typed answers carry every member a v11.0.0 platform sends: the refusal's policy, the edition's catalog_digest, registry_version and catalog_fixture, a TemplateOmissionReport on the publication and the activation, and the system control's name; mandatory is a plain bool. active() returns None only for the platform's 404 nothing_active. The route-deprecation record keys on the route template for a path that carries an id: both request helpers take path_id and build the path from the template, and a census covers the platform's seven deprecated families. The simulation methods' notes carry the ruled wording and drop the typed simulate that does not exist. The handshake texts say the engine refuses on every edition. The typed-policies example finds its default document from its own location, prints the omission report before it activates, and fails the run on a refused publication or activation; runtime-e2e/v11_examples runs both examples from outside the tree, and the typed_policies leg asserts the new members live.

Signed-off-by: Saurabh Jain <saurabh.jain@getaxonflow.com>
First review folds. No SDK release has shipped typed policies, so the docs no longer describe a release that read any 404 as nothing active; active() keeps None only for nothing_active and cites getaxonflow/axonflow-enterprise#4255. The v11_examples leg copies the package sources out of the tree before installing, checks by real path that the examples import the SDK from its virtual environment, runs them with no PYTHONPATH and turns telemetry off. The typed_policies leg asserts registry_version. _route_with_id cites #263. Issue references use their full form.

Signed-off-by: Saurabh Jain <saurabh.jain@getaxonflow.com>
Typed policy authoring is new in this release, so the CHANGELOG describes its members, active() and the example's behaviour in the entries for the feature and the examples rather than as a separate change. The v11_examples leg unsets PYTHONPATH once, exits 2 when a virtual environment cannot be built, and checks where the SDK imports from with the examples' own directory first on sys.path, reporting an import failure as such.

Signed-off-by: Saurabh Jain <saurabh.jain@getaxonflow.com>
The baseline keys each pre-existing finding by file, line and column, and the route-template helper and docstrings added to axonflow/client.py moved 15 of them down the file. Each entry's source text is unchanged from main (verified line by line); no new finding is added. Rebuilt by the script's own --write-baseline.

Signed-off-by: Saurabh Jain <saurabh.jain@getaxonflow.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant