Importing Legacy Policies into the Typed Model
The import is an Enterprise feature and runs as a command against your database. It is not a button in the portal, and it does not run on its own. Policies you authored in v10.x are not carried forward into v11 automatically: on Enterprise, import them before or at upgrade and activate the result; on Community, re-author them as a typed policy document. Both editions are affected by the change described under What changes about the legacy tables.
AxonFlow's policy model is converging on one authoring surface. Until now, two families of policy were stored and edited separately: static policies (the pattern-based detection rules the platform ships, plus any you added) and dynamic policies (condition-based tenant rules). In v11 both are replaced by a single typed model in which every rule is authored, validated, signed and versioned the same way, and the legacy tables decide nothing.
This page covers the move: how to import what you have, what the dry run tells you before anything changes, and what happens to the old tables afterwards.
Before you start
You need three things:
- A database connection with the credentials your deployment normally uses.
- The typed authoring vocabulary your deployment uses: its own by default, or the one
AXONFLOW_TYPED_AUTHORING_CATALOGnames if it is set. The import validates every document it produces against the same vocabulary the editor does, so a policy that imports cleanly is a policy the editor will accept. - Two people. A publication records an author and at least one approver who is not the author. That rule is not waived for a migration. An organization with fewer than two eligible approvers may enable self-approval, and each self-approved publication records the reason on its audit row.
The three steps
The import is a capture, a dry run and a write. They are separate on purpose: the capture is what you can archive and re-run against, the dry run is what you read, and the write is the only step that changes anything.
1. Capture
scripts/legacy-policy-capture.sh ./legacy-capture
This reads both policy tables losslessly - every column, by name - and writes
capture-owner.json and capture-approle.json. Nothing is interpreted at this stage, which is
what makes the capture something you can keep: a later import can be diffed against it to show
exactly what moved.
2. Dry run
axonflow-policy-import \
-capture ./legacy-capture/capture-owner.json \
-ledger platform/decision/registry/detectors_census_superseded.tsv \
-plane proxy_tier \
-org acme \
-edition enterprise \
-database-url "$DATABASE_URL"
A dry run is the default. Without -write the command reads, plans, prints and touches
nothing.
Every flag in that command is required, and none is guessed for you. Three need a word:
-planenames which enforcement path's semantics you are importing. AxonFlow evaluates policy on twelve paths and they do not all read the legacy tables the same way - a dozen rules genuinely resolve to a different action depending on which path asked. Choosing one silently would decide, on your behalf, which of two behaviours your organization ends up enforcing. Runaxonflow-policy-import -list-planesto see them.-editionnames the edition the imported documents are authored under:community,evaluationorenterprise. It has no default, because a default ofenterprisewould let a Community deployment publish constructs it cannot author.-ledgeris the supersession record. Without it the tool cannot tell a superseded pair of rules from two unrelated ones - see The superseded-rule trap.
-database-url (or DATABASE_URL in the environment) is required even for a dry run: the plan is
validated against the authoring vocabulary, which is resolved from your deployment's database.
3. Write
axonflow-policy-import ... -write \
-database-url "$DATABASE_URL" \
-author 'User::portal:[email protected]' \
-approver 'User::portal:[email protected]' \
-fixtures ./fixtures.json
A write publishes; it does not activate. The imported versions appear in the typed policy editor as published, unactivated versions. Somebody other than the author reviews the diff and promotes them. Nothing your deployment enforces changes at the moment of the import.
Activating an imported document replaces your organization's 22 organization-editable shipped policies with the document's contents, as activating any organization document does. Carry the ones you want to keep; the publish response lists any your document omits.
-fixtures is the set of cases the publication gauntlet runs against the imported policy. The
tool will not invent them: a document published with no evidence has not been shown to do
anything, and a case generated from the same compilation it is meant to test would only prove
that the compiler agrees with itself.
Running the same import twice produces one set of documents. The second run reports each document as already published and writes nothing.
What the dry run shows you
The report has four sections, and none of them is a summary you can skim past.
Every row, one line each
One line per captured row - including the rows that produce no policy - with what the tool read for it and what it wrote:
static_policies drop_table_prevention read action=block category=dangerous_queries -> imported
wrote: [legacy:static_policies:drop__table__prevention:proxy_tier]
generation: superseded (won, stronger_action): ...
static_policies a_disabled_row read action=- category=- -> not_imported_disabled
reason: row_excluded_by_legacy_predicate enabled = false
The outcomes are:
| Outcome | Meaning |
|---|---|
imported | Became typed policy. |
imported_with_preserved_defect | Became typed policy that reproduces a known defect in the original rule rather than repairing it. The reason names the defect. Repairing it is a decision for you to make afterwards, in the editor. |
not_imported_disabled | The rule exists and is switched off, so it enforces nothing today. Importing it as active policy would strengthen what you enforce, so it is recorded and not emitted. |
not_imported_unexpressible | The rule could not be expressed on this path. The reason says why. |
not_imported_superseded_collapse | You asked for a collapse and this rule lost the comparison. This is the only outcome that removes enforcement, and it never happens by default. |
Every captured row appears exactly once. There is no "skipped".
Both generations of every superseded pair
drop_table_prevention (block, present=true, seeded_by=010_policy_tables.sql)
superseded by sys_sqli_drop_table (warn, present=true)
winner: drop_table_prevention [stronger_action]
why: the superseded generation "drop_table_prevention" enforces "block" and its superseder
"sys_sqli_drop_table" enforces only "warn", so keeping the newer row alone would LOWER
enforcement; the stronger action is carried forward
The decisions the tool refuses to make for you
Three classes come back as operator decisions rather than as choices made quietly:
- a rule whose action depends on which path asked - collapsing those silently would strengthen or weaken the control;
- a rule requiring approval that cannot be expressed in the new model, because the old tables store neither an eligible approver list nor a quorum;
- a superseded pair where one side carries an action the tool will not rank.
The documents it would publish
Each with its policy count and whether it passed validation. A document the vocabulary rejects is reported with the findings that say why, alongside everything else - the dry run does not stop at the first problem.
The superseded-rule trap
Some of the platform's shipped rules were re-seeded under new identifiers with corrected patterns, leaving two generations of the same rule live side by side.
The older generation is sometimes the stronger one. A later change relaxed a set of system
defaults to warn, selecting them by category - and the pre-canonical rules were filed under
categories that change did not name, so they escaped it and kept their original block and
redact actions.
| Older rule | Its action | Newer rule(s) | Their action |
|---|---|---|---|
drop_table_prevention | block | sys_sqli_drop_table, sys_sqli_stacked_drop, sys_sqli_drop_database | warn |
truncate_prevention | block | sys_sqli_truncate | warn |
sql_injection_or | block | sys_sqli_or_true, sys_sqli_or_string | warn |
sql_injection_union | block | sys_sqli_union_select, sys_sqli_union_injection | warn |
pii_ssn_detection | redact | sys_pii_ssn | warn |
An import that treated "superseded" as "safe to drop" would quietly lower DROP TABLE, TRUNCATE,
two forms of SQL injection and SSN handling to warn.
So the tool does two things. By default it imports both generations, so an import never
lowers what you enforce; in v11 both generations also ship enabled, the older one among the
organization-editable policies (Shipped Policy Posture). And where you explicitly ask for a
collapse with --collapse-superseded, it keeps the stronger action, never the newer rule -
and prints both generations either way, so the choice is visible whether or not you take it.
What changes about the legacy tables
After you upgrade, static_policies and dynamic_policies become read-only to the
application. This applies to every deployment, Community and Enterprise alike.
What they no longer do: decide. v11's policy decision engine does not read them, so a rule you wrote there stops deciding when you upgrade unless you import it and activate the result. Nothing compares the legacy rules with their typed replacements.
What still works: reading them, for export. The legacy read routes stay in v11 as a
read-only, deprecated export surface, and v11.1 removes them. Every response on these routes
(reads, the 409 on writes, 401 and 503) carries
Link: </api/v1/typed-policies>; rel="successor-version" and X-AxonFlow-Removed-In: v11.1,
and from the v11.0.0 release a Deprecation header (RFC 9745) dated to the release.
What stops working: creating, editing or deleting a policy through the legacy policy APIs,
which answer 409 LEGACY_POLICY_WRITE_FROZEN naming the typed route, and the legacy portal
pages, which are removed.
Automation that creates or updates policies through the legacy static_policies /
dynamic_policies endpoints fails with 409 on a deployment running the application database
role. On one still connecting as the database owner its writes are accepted and decide nothing.
Move it to the typed authoring surface either way.
Your existing rows are not deleted: they stay in place and readable for export. What decides for your organization is the shipped policy set and any typed document you activate, so import and activate before you rely on v11 to enforce a rule you wrote.
Where the imported policy lives
Imported documents are stored in your database, not in a portal process. That means:
- a published version survives a restart, a redeploy and a scale event;
- a second portal replica sees what the first one published;
- every publication, promotion and rollback leaves a durable, audited record naming who did it and when.
You can confirm which storage your deployment is using: the customer portal's
GET /api/v1/typed-policies/settings reports persistence as database once the upgrade is
applied, and process before it.
The same endpoint reports signing_key_custody separately, and it currently reads process.
Each portal process signs with a key it generates and records the public half of, so every
published version stays verifiable across restarts and replicas - but the signing identity
itself is per-process. A durable signing identity is managed key custody, which is tracked
separately from this change.
Related
- Managing Policies - the day-to-day surface.
- System Policies - the rules the platform ships.
- Policy and Identity Control Plane - what decides a request in v11.
- v10 → v11 Migration Guide - everything that changes on upgrade.
