Skip to main content

Importing Legacy Policies into the Typed Model

Enterprise, and operator-run

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_CATALOG names 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:

  • -plane names 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. Run axonflow-policy-import -list-planes to see them.
  • -edition names the edition the imported documents are authored under: community, evaluation or enterprise. It has no default, because a default of enterprise would let a Community deployment publish constructs it cannot author.
  • -ledger is 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:

OutcomeMeaning
importedBecame typed policy.
imported_with_preserved_defectBecame 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_disabledThe 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_unexpressibleThe rule could not be expressed on this path. The reason says why.
not_imported_superseded_collapseYou 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 ruleIts actionNewer rule(s)Their action
drop_table_preventionblocksys_sqli_drop_table, sys_sqli_stacked_drop, sys_sqli_drop_databasewarn
truncate_preventionblocksys_sqli_truncatewarn
sql_injection_orblocksys_sqli_or_true, sys_sqli_or_stringwarn
sql_injection_unionblocksys_sqli_union_select, sys_sqli_union_injectionwarn
pii_ssn_detectionredactsys_pii_ssnwarn

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.

If you script against the legacy policy APIs

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.

Signing keys are a separate question

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.