Skip to content

feat: expose the PST as typed nodes via policies_to_pst - #108

Merged
skuenzli merged 22 commits into
k9securityio:mainfrom
h0rv:feat/pst-json
Sep 1, 2026
Merged

feat: expose the PST as typed nodes via policies_to_pst#108
skuenzli merged 22 commits into
k9securityio:mainfrom
h0rv:feat/pst-json

Conversation

@h0rv

@h0rv h0rv commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Rebased on current main. Nine commits, all part of this change.

The last five are a response to @skuenzli's review, and I have answered the questions on the PR itself. Short version: the two node types nothing could produce are gone, entity_uids no longer reports an empty result for input it cannot walk, the round trip he asked about is here, and there is now a test that fails CI when cedar-policy adds a PST variant.

What it adds

policies_to_pst(policies) -> cedarpy.pst.PolicySet. It parses Cedar policy text and hands back the PST as typed Python objects, one frozen dataclass per cedar_policy::pst node kind. You match on real types instead of digging through string keys:

match clause.expr:
    case BinaryOp(op="eq", left=GetAttr(base=Var(name="resource"), attr="status")):
        ...

Rust builds the objects directly. It walks pst::Expr once and calls the matching Python constructor for each variant, with the child nodes already built. There is no JSON step and no second walker.

PolicySet.from_pst(nodes) and PolicySet.to_pst() are the other direction, so a set can be taken apart as nodes, changed, and handed back to the engine:

nodes = policies_to_pst('permit(principal == User::"alice", action, resource);')
policy = nodes.static_policies["policy0"]
retargeted = dataclasses.replace(policy, principal=ScopeEq(EntityUid(EntityType("User"), "bob")))
edited = dataclasses.replace(nodes, static_policies={"policy0": retargeted})

PolicySet.from_pst(edited)     # authorizes for bob, not alice

Reading back needs no crate internals: cedar_policy::Policy::from_pst, Template::from_pst and PolicySet::from_pst are all public in 4.12. The enums are #[non_exhaustive], which blocks an exhaustive match from outside the crate but not constructing a variant that exists, so the reader builds pst::Expr values directly and lets the engine's own from_pst do the validating. policies_to_pst(text) is now just PolicySet.from_str(text).to_pst().

Why typed objects instead of JSON

Cedar already checked all of this on the Rust side. A clause cannot hold a slot. A clause cannot hold an unknown. Every expression is well formed. Handing back a JSON string throws those guarantees away and makes every caller re-check them by matching on strings, usually getting it wrong. A typed object carries the guarantee in the type, so there is nothing left to re-check.

The same idea drives the rest of the design: anything Cedar models as a closed set stays a closed set in Python, so a type checker can catch a mistake instead of a caller finding it at runtime.

Operator names are closed. UnaryOp.op and BinaryOp.op are Literal aliases listing every name Rust can emit (UnaryOpName, 18 names; BinaryOpName, 24). Template.effect, Var.name, and Slot.name are the same. So a match over one can be proven exhaustive with assert_never, and a typo like op == "equals" is a type error rather than a branch that never runs.

Bool and Long literals are separate nodes. bool is a subclass of int in Python, so one node holding either could not tell Cedar's Bool from its Long: isinstance(v, int) is true for True. They are BoolLit and LongLit, alongside StringLit and EntityLit.

Entity type names keep their namespace. EntityType(basename="User", namespace=("MyApp",)) instead of the string "MyApp::User", so no caller has to split on :: to get the namespace back. str() gives the Cedar form.

Every collection is parameterized. tuple[Expr, ...], tuple[Clause, ...], and so on. A bare tuple is tuple[Any, ...], which means the union evaporates as soon as you index into it.

HasAttr.attrs rejects an empty path, which is the NonEmpty<SmolStr> invariant Rust holds.

Nodes are values. Frozen, slotted (a policy set is many small objects, and there is no reason for each to carry a __dict__), and hashable, so they work as dict keys and set members. Mapping fields are declared Mapping, which has no mutating methods, and hold a FrozenMap at runtime, which is a dict subclass that raises on mutation. So a write to record.fields fails both in the type checker and at runtime, while dataclasses.asdict(), json.dumps(), and comparing against a plain dict all still work.

pst.entity_uids(node) returns every entity uid named anywhere under a node, at any depth, which is what you need in order to decide which entities to load before evaluating. It takes a node, or a mapping or tuple of nodes such as a PolicySet's templates. Anything else raises TypeError, rather than returning an empty frozenset that reads as "this policy names no entities". That mattered: passing the engine's own PolicySet handle instead of a pst.PolicySet used to silently report nothing.

__all__ declares the module's public surface.

Keeping up with cedar-policy

Every pst enum is #[non_exhaustive], so no exhaustive match is possible from outside the crate and the compiler cannot tell us when a variant is added. Two things stand in for that.

tests/unit/test_pst_variant_coverage.py reads the variant names, and the cargo feature each one needs, out of the cedar-policy-core source that Cargo.lock resolves to, and compares them against pst_variants.json in the repo. A bump that adds, removes or renames a variant fails and names it. It is feature-aware, because which variants exist depends on what Cargo.toml turns on: ResidualError needs tpe, VariadicOp needs variadic-is-in-range. The modelled side is read off cedarpy.pst.Expr rather than listed in the test, so a node type added to the union without a matching Rust variant fails too. Regenerate with python tests/unit/test_pst_variant_coverage.py after deciding what to do about the new variant.

The corpus suite also runs all 7,600 fuzzer-generated policies through policies_to_pst and back through from_pst. That catches a regression in what is already modelled, though not a new variant, since a new variant needs syntax the existing corpus does not contain. Today 7,551 convert and rebuild into an identical node tree. The other 49 are cedar's own PST construction rejecting extension functions called with the wrong arity (lessThan with 5 arguments, decimal with 4, and so on); none is the "unrepresentable variant" error this code raises for a node kind it does not model, which is what the test actually asserts.

What this does not cover

Static policies and unlinked templates only. A residual from is_authorized_partial cannot be represented this way. pst::Template's clauses are pub(crate) and can only be added through methods that validate each one, and that validation rejects any clause holding an unresolved unknown(...) node. Every non-trivial residual from is_authorized_partial has one. Confirmed by calling to_pst() on real residuals for a permit and a forbid; both raised.

For the same reason Unknown is not a node type here any more, and neither is ResidualError: that variant is behind cedar's tpe feature, which this PR does not enable, so it could not occur at all. ResidualError arrives in #109, where the feature is on and TPE really does emit it.

is_authorized_partial and residuals are untouched.

Notes for review

pst::Expr and several of its operator enums are #[non_exhaustive] upstream, so the Rust match needs a wildcard arm no matter what. That arm raises ValueError naming the variant it could not handle. It should never fire on this cedar-policy version. It is there so a future engine bump that adds a variant fails loudly instead of quietly building the wrong thing, and the audit above is what turns that into a CI failure rather than a runtime surprise.

No Cargo change is needed. There is no pst cargo feature: cedar_policy::pst is re-exported ungated in 4.12. Adding features = ["pst"] fails to resolve, and cargo helpfully points at tpe as the similar name, which is #109's feature.

I looked at pyo3 complex enums first (a #[pyclass] on a Rust enum with struct variants). They work, including the recursive case with Py<T> fields, and pyo3 generates __match_args__ so both keyword and positional patterns match. I did not use them: calling a plain Python constructor from Rust gives real dataclasses with no intermediate representation, and asdict / json.dumps come for free. A pyo3 complex enum would need a hand-written dict conversion instead.

One thing worth knowing if you touch the mapping fields: types.MappingProxyType cannot be used here, because dataclasses.asdict() calls copy.deepcopy and a mappingproxy cannot be pickled. That is why FrozenMap subclasses dict rather than wrapping one.

The audit test decodes cargo metadata as UTF-8 explicitly rather than using subprocess text=True. Cargo's metadata is UTF-8 and is not ASCII-only, and text=True decodes with the locale codec, which fails partway through on a Windows runner. That is a real failure I hit on CI here, not a precaution.

Testing

Built with maturin develop on macOS arm64, Python 3.12.13.

Check Result
pytest tests/unit 269 passed, 14 subtests
pytest tests/integration/test_cedar_integration_tests.py 78 passed
pytest tests/integration/test_cedar_corpus_tests.py 60801 passed
mypy --strict cedarpy/pst.py clean
cargo clippy --all-targets 10 warnings, the same 10 as on main, none in this diff

Tests cover every clause and scope constraint shape, every literal kind, set and record, has (including the multi-attribute path, checked against real parser output: resource has a.b lowers to two chained single-attribute nodes, not one node holding two attrs), like, is / is in, if / then / else, operators, a template with a slot, keyword and positional pattern matching, asdict / json.dumps, and error handling. Plus the namespaced entity type, BoolLit against LongLit, every emitted operator name being present in its Literal alias, mapping fields refusing mutation, nodes working as dict keys, the empty HasAttr path being rejected, and entity_uids across scope, conditions, sets, records and template links.

The new ones cover the round trip (static policies, a template with slots, a linked template, an edited node tree that authorizes differently afterwards, and both error paths), entity_uids refusing what it cannot walk, and Unknown being rejected by cedar rather than modelled here.

I checked the typing claims with mypy rather than assuming them. An exhaustive match over Expr ending in assert_never passes, and each of these is an error: op="nope", Var("principle"), EntityUid(type="User", ...), a list where a tuple belongs, e.op == "equals", and writing to a mapping field.

Not run: make benchmark-compare. The repo's own guidance says that comparison is load-sensitive and the machine was busy.

@h0rv h0rv changed the title feat: expose the PST as JSON for static policies and templates feat: expose the PST as typed nodes via policies_to_pst Aug 21, 2026
@h0rv
h0rv marked this pull request as ready for review August 28, 2026 13:42
h0rv and others added 2 commits August 28, 2026 18:08
Adds policies_to_pst, which parses policy text into cedarpy.pst nodes:
frozen dataclasses, one per cedar_policy::pst node kind. A consumer
pattern-matches on real types instead of walking a dict and matching
string keys.

Rust walks pst::Expr once (matching what to_json_str already parses,
just to a different target) and constructs the corresponding dataclass
directly by calling its constructor from Rust. No JSON in between, no
second Rust converter: the typed tree is the only thing Rust builds.
dataclasses.asdict() and json.dumps() work on the result for free,
since the nodes are plain dataclasses.

Several pst types are #[non_exhaustive] upstream, so the matches on
them keep a wildcard arm regardless. It raises ValueError naming the
variant rather than building something silently wrong.

Static policies and unlinked templates only. A residual from
is_authorized_partial cannot convert this way: PST's own policy type
rejects any clause containing an unresolved unknown(...) node, and
every non-trivial residual has one. Confirmed by calling to_pst() on
real residuals, not from the changelog. is_authorized_partial and its
residuals field are untouched.

Closes part of k9securityio#107.
Operator names, effect, var and slot names become Literal aliases; Bool and
Long literals become distinct nodes; entity type namespaces stay structured;
collection fields are fully parameterized; nodes are hashable and their
mapping fields are read-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@skuenzli

skuenzli commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Hi @h0rv - thank you for the PR!

I've spent quite a while working to understand this change and I have several questions:

  1. Do you consider this feature addition complete? Both this PR and feat: expose type-aware partial evaluation (TPE) as tpe_authorize #109 changed significantly during my review.

  2. How are you using this new API in your own application(s)? I'm trying to understand the use case and how broad it is.

  3. What is the maintenance process for adding coverage to the PST entity types?

  4. How should cedar-py verify coverage of the PST types on an ongoing basis? One thought was to run some portion of the corpus test policies or cedar-policy integration test policies through policies_to_pst to verify they parse.

  5. Should we add cedarpy.PolicySet#from_pst so that we can initialize a PolicySet from a pst? This would enable round-tripping policies. That would enable testing and other things like surgical edits, I'm guessing.

  6. I'm confused about residuals. Can you please help me understand how entity_uids is meant to be used with them? The commit message says residuals are the motivation, but the PR body says residuals can't convert to a PST at all (pst::Template rejects unknown(...)), and the tests only ever call entity_uids on static policies and templates. What do you actually call it on in your application?

h0rv and others added 5 commits August 31, 2026 14:33
Answers 'which entities does this policy reference', which is what a
caller needs to decide what to load before evaluating. Walks the fields
generically, so a node kind added later is covered.

Static policies and templates only, since that is all policies_to_pst
produces. The engine computes the same thing per residual policy, but
only on a crate-internal type, and a residual cannot become a PST here
anyway, so there is nothing to mirror yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A policy set is many small nodes, so dropping the per-instance __dict__ is
worth having. Also uses PEP 604/585 syntax directly rather than typing
aliases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Unknown` and `ResidualError` were in the `Expr` union but neither is
reachable. A `pst::Template`'s clauses are `pub(crate)` and only settable
through methods that validate each one, and that validation rejects any
clause holding an `Unknown`; scope constraints hold no expression. So no
template `policies_to_pst` can receive contains one, which the test now
pins to cedar's own error.

`ResidualError` only exists with cedar's `tpe` feature, which this build
does not enable, so Rust was looking up a class it could never use.

Also moves `Mapping` to `collections.abc` and sorts `__all__`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The walk returned an empty frozenset for anything it could not walk, so
passing the engine's own `PolicySet` handle instead of a `pst.PolicySet`
read as "this policy names no entities". It now takes a node, or a
mapping or tuple of nodes, and raises `TypeError` otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PolicySet.from_pst(nodes)` reads the dataclasses back into
`cedar_policy::pst` types and hands them to the engine's own
`PolicySet::from_pst`, so a set can be taken apart, rewritten, and
authorized against. `PolicySet.to_pst()` is the other half, which makes
`policies_to_pst(text)` the same thing as
`PolicySet.from_str(text).to_pst()`.

Reading dispatches on the dataclass name, mirroring the way the forward
direction dispatches on the Rust variant. Constructing the pst types
directly is allowed: the enums are `#[non_exhaustive]`, which blocks an
exhaustive match from outside the crate but not building a known variant.

Every one of the 7,551 corpus policies that converts to nodes rebuilds
into an identical node tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every pst enum is `#[non_exhaustive]`, so no exhaustive match is possible
from outside the crate and the compiler cannot report a variant cedarpy
does not model. The audit reads the variant names, and the cargo feature
each needs, out of the cedar-policy-core source `Cargo.lock` resolves to,
and compares them against a snapshot here. A bump that adds, removes or
renames a variant fails and names it.

The corpus suite also runs all 7,600 fuzzer-generated policies through
`policies_to_pst` and back. That catches a regression in what is already
modelled, but not a new variant, since a new variant needs syntax the
existing corpus does not contain. Today 7,551 convert and rebuild
identically; the 49 that do not are cedar's own PST construction
rejecting extension functions called with the wrong arity, none of them
the "unrepresentable variant" error cedarpy raises for an unmodelled node.

Also fixes the entity_uids output in the README, which showed a uid the
policy above it does not name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@h0rv

h0rv commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

All six are handled in code. #108 is now 1c59266 and #109 is 60ae834.

  1. Yes, complete. Sorry about the churn. I marked these ready and then pushed the typing rework and two rebases the same day, which is a bad thing to review against. The heads are stable now.

  2. We read policies as data rather than as text. policies_to_json_str already allowed that, but it meant walking nested dicts keyed by strings, and nothing tells you when you have missed a node kind. With typed nodes a type checker proves the match is exhaustive.

3 and 4. Every pst enum is #[non_exhaustive], so an exhaustive match is impossible from outside the crate and the compiler can never report a new variant. I added tests/unit/test_pst_variant_coverage.py. It reads the variant names, and the cargo feature each one needs, from the cedar-policy-core source that Cargo.lock resolves to, then compares them to a snapshot in the repo. A version bump that adds or renames a variant fails and names it. I also added your corpus idea. All 7,600 fuzzer policies now go through policies_to_pst and back. 7,551 convert and rebuild into an identical node tree. The other 49 fail inside cedar's own arity checks, not ours.

  1. Added, with to_pst as the other half. Policy::from_pst and PolicySet::from_pst are public in 4.12, so this needs no crate internals.

  2. You are right, and the commit message was the wrong part. The motivation is TPE residuals in feat: expose type-aware partial evaluation (TPE) as tpe_authorize #109, which are pst.Template values. Residuals from is_authorized_partial cannot convert at all. I reworded the message and added the missing tests on feat: expose type-aware partial evaluation (TPE) as tpe_authorize #109.

Two things I fixed while checking that. entity_uids returned an empty set for input it could not walk, so passing the engine's PolicySet handle looked like "this policy names no entities". It raises now. I also dropped Unknown and ResidualError from this PR, because neither can occur here. ResidualError arrives in #109, where the tpe feature is on.

The changelog entry, README section, and docstrings I added were longer
and differently shaped than everything around them.

- Changelog: three bullets instead of four, 233 words instead of 471. The
  design detail moved to the README, and the audit test came out, since a
  changelog lists user-visible changes. No indented continuation
  paragraphs, which this changelog does not use anywhere else.
- README: unwrapped lines and `*` bullets to match the surrounding
  sections, examples that assert their own results the way the other
  sections do, and no colons joining clauses. All four code blocks were
  executed as written.
- Docstrings: cut the rationale down to the part a reader needs. The
  `PolicySet` class docstring now lists `from_pst` alongside the other
  two constructors.
- Dropped a banner comment in lib.rs; nothing else in the file uses one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@skuenzli

Copy link
Copy Markdown
Contributor

Thanks for the substantial updates, which I think improved the PR a lot. I'll review and share feedback.

The pst module tracks the Cedar engine rather than cedarpy's usual
pure-additive API stability: the mirrored Rust enums are non_exhaustive
and grow with the language, so engine bumps may add node types, fields,
or Literal members in cedarpy minor releases, and unmodelled syntax
raises ValueError until modelled. Stated in the module docstring (full
detail), the to_pst/policies_to_pst pydocs (one line, pointing at the
module docs), and the README (one line).

Co-Authored-By: Claude
Measured on the v4.12.0 corpus: 7,551 of 7,600 policies convert, and all
49 cedar-side rejections are one shape - extension functions called with
the wrong arity (unchecked at parse, checked at PST construction). Assert
that shape per rejection instead of a 0.99 conversion floor: the floor
let up to 76 unexplained cedar-side failures pass silently, while the
category check fails on the first rejection of any new kind, named.
Corpus shrinkage is guarded directly (> 7000 files).

Co-Authored-By: Claude
Comment thread cedarpy/_internal.pyi
def policies_to_pst(s: str) -> "cedarpy.pst.PolicySet": ...


def validate_policies(policies: str, schema: Union[str, "Schema"]) -> str: ...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment thread cedarpy/__init__.py
"""Parse Cedar policy text into typed PST nodes from cedarpy.pst."""
return _internal.policies_to_pst(policies)


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment thread cedarpy/pst.py

for root in roots:
walk(root)
return frozenset(found)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

"VariadicOp": {
"IsInRange": null
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

README: 'mirroring' participle tail becomes a clause; residuals are
'represented', not 'parsed'; the Mapping/FrozenMap sentence splits at
its garden path. pst.py: normalize the contract paragraph to the
module's single-backtick style; unknot the FrozenMap dependency
rationale's trailing clause.

Co-Authored-By: Claude
The README serves engineers new to cedar-py: what the library can do
and one clear example per feature, with advanced material in
docs/guides (the pattern partial-authorization-guide.md set). The two
PST sections shrink to one section with the match example, the
engine-tracking line, and a pointer to the new
docs/guides/policy-syntax-tree-guide.md, which carries the closed-set
guarantees, node value semantics, entity_uids, and the from_pst
rewrite example in full.

Co-Authored-By: Claude
Codifies the README quick-start doctrine (advanced features get a short
section plus a docs/guides guide) and the prose rules applied in the
PR k9securityio#108 copy review, so contributions start from the standard rather
than getting edited toward it.

Co-Authored-By: Claude
Cedar API gotchas gains the from_pst/to_pst facts verified during the
PR k9securityio#108 review: the lossless cache makes to_pst(from_pst(x)) == x
trivially true, and upstream PolicySet equality is source_loc-sensitive,
so fidelity tests must go through rendered text or compare at the PST
level. Cedar engine upgrades gains the pst-mirror maintenance step
(converter arms, pst.py types, regenerated variant snapshot).

Co-Authored-By: Claude
Lead with the solution (inspect policies as a typed tree) rather than
the function name, per the copy standard's quick-start doctrine.

Co-Authored-By: Claude
…line

'New engine variants' was Rust-enum jargon a cedar-py user has no way
to interpret. The line now names what the reader experiences: engine
upgrades can bring Cedar Policy syntax changes, the cedarpy.pst mirror
grows to model them, and unmodelled syntax raises ValueError until it
does.

Co-Authored-By: Claude
Both conversion directions recurse once per expression level, and deep
enough input overflows the stack: a RecursionError from Python's walk,
a process abort from the Rust builder. build_expr and read_expr now
track depth and raise ValueError past 100 levels, enforced
symmetrically so any tree to_pst produces, from_pst accepts.
entity_uids's walk becomes iterative (explicit work stack), so reading
hand-built trees has no depth ceiling at all.

The limit is a behavioral contract: raising it later is backwards
compatible; lowering it would be breaking. 100 gives ~6x headroom over
the deepest expression in the fuzzer corpus (17 tree levels across
7,551 policies, measured 2026-09-01). The boundary is pinned by unit
tests (98 nested sets convert, 99 raise) and documented in the module
docstring, function pydocs, the guide, and the changelog.

Verified: unit 274, integration 78, corpus 60,801 all pass.

Co-Authored-By: Claude
Fix the stale README pointer (the node types and guarantees moved to
the Policy Syntax Tree Guide), credit @h0rv with the PR k9securityio#108 link per
the contribution convention, correct the engine-upgrade entry's claim
that pst is an experimental feature (it is unconditionally public;
cedarpy simply did not use it at 4.8.2), and reorder the Unreleased
sections to Keep a Changelog order (Added, Changed, Removed, Security).

Co-Authored-By: Claude
@skuenzli

skuenzli commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Hi @h0rv - thank you for the updates. I reviewed the revised PR and it was very good. Rather than round-trip more feedback, I pushed my review changes directly to your branch (309f1d54..56bfab36, 15 commits). Summary:

Behavior change (the one to look at first):

  • Expression nesting is now capped at 100 levels in both conversion directions. build_expr and read_expr track depth and raise ValueError past the limit; deep enough input previously meant a RecursionError from Python or a stack overflow (process abort) from the Rust builder. I measured the fuzzer corpus to size the limit: the deepest expression across all 7,551 convertible policies is ~13 levels, so 100 gives ~6x headroom. The limit is a documented behavioral contract: raising it later is backwards compatible, lowering it would be breaking. Boundary pinned by unit tests (98 nested sets convert, 99 raise).
  • entity_uids's walk is now iterative (explicit work stack), so it has no depth ceiling at all.

Tests:

  • The corpus conversion test now asserts cedar-side rejections by category instead of the 0.99 conversion-rate floor. All 49 current rejections are one category, extension-function arity, so the first rejection of any new kind fails the test and names the file. Corpus shrinkage is guarded directly (> 7000 files).

Docs:

  • Documented the cedarpy.pst compatibility contract (tracks the engine; unmodelled syntax raises ValueError) in the module docstring, the to_pst/from_pst/policies_to_pst pydocs, and the README.
  • Moved the full PST documentation to docs/guides/policy-syntax-tree-guide.md, following the pattern the partial-authorization guide set. The README keeps one section: intro, the match example, the engine-tracking line, and a pointer. This reflects a README doctrine I codified in CLAUDE.md while reviewing: the README serves new and intermediate users; advanced features get guides.
  • Editorial pass on the copy, plus docstrings explaining the pst_variants.json format and why FrozenMap is hand-rolled rather than a dependency.
  • CHANGELOG: fixed a stale README pointer, added the feat: expose the PST as typed nodes via policies_to_pst #108 credit, and corrected the engine-upgrade entry (pst is public API, not an experimental feature - it was just unused at 4.8.2).

Everything verified locally at the pushed tip: release build, 274 unit / 78 integration / 60,801 corpus tests pass. Please flag anything you'd have done differently - particularly the depth limit, since it constrains input your applications might produce.

@skuenzli
skuenzli merged commit 81d4a36 into k9securityio:main Sep 1, 2026
8 checks passed
h0rv added a commit to h0rv/cedar-py that referenced this pull request Sep 1, 2026
Aligns this branch with the review standards applied to k9securityio#108.

- The pydocs and Rust doc comments now lead with what each entry point does
  and name the error each raises and its condition: tpe_authorize,
  tpe_reauthorize, and the two _internal stubs, which had no docstrings.
- TpeAuthzResult had no class docstring; it now says decision is None exactly
  when the unknowns can still change the outcome. residual_policies gets the
  docstring residual_policy_set already had, since the difference between the
  two views is the part callers get wrong.
- pst.ResidualError documents that PolicySet.from_pst raises TypeError on a
  node tree containing one, with a test. read_expr has no arm for it, so this
  was already the behaviour, undocumented and untested.
- CLAUDE.md records the tpe Cargo feature and what it gates, the TpeResponse
  schema borrow that keeps it out of a pyclass, the PartialEntities
  wholly-present-or-absent rule, and that the two residual views name
  different entities.
- The pst engine-bump step notes that pst_variants.json records each
  variant's cfg gate, so a variant only this build's features reach is
  audited as such.
- The 4.12 engine entry said the breaking changes were confined to features
  cedarpy does not enable, listing tpe. This branch enables tpe, so the
  sentence now reads "did not then enable".
h0rv added a commit to h0rv/cedar-py that referenced this pull request Sep 1, 2026
Same editorial pass k9securityio#108 got. No behavior change, and the only Rust edits are
doc comments.

Splits the sentences that were joined with a semicolon or a colon, drops the
words that carried no information ("sanctioned", "matters", "drops straight
into"), and names the actor where an inanimate subject was taking an action
verb. TpeClassification, _TpeInputs, and pst.ResidualError get first lines that
say what the thing is.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants