Migration Guide
One place to find every breaking or deprecated change the library introduces. Sections are newest-first.
0.8.x → 0.9.0
fail_on_breach rejects injected types no shape targets (#50)
traverse(..., fail_on_breach=True) now treats each node it injects whose
types no boundary shape targets as a violation, rolls the injection back, and
raises MembraneBreachError. Before 0.9.0 such nodes passed, because SHACL
validates only the nodes its shapes target.
A traversal breaks under this change when its portal injects auxiliary node types (for example measurement or provenance nodes) that the target’s shapes do not name. Either add shapes that target those types, or exempt them in the target’s boundary graph:
<urn:holon:target> cga:permitsType ex:Measurement .
cga:permitsType also exempts subclasses. To report such nodes without
failing the traversal, declare cga:untargetedTypeSeverity sh:Warning (or
sh:Info) on the holon instead. validate_membrane() and dry_run() keep
their health; they list untargeted nodes in MembraneResult.untargeted at Info
unless the holon declares a severity.
Unrecognized SHACL severities compromise the membrane (#30)
A SHACL result whose severity is not sh:Violation, sh:Warning, or
sh:Info, or that has no severity, now counts as a violation. Before 0.9.0
such results were dropped. A boundary shape that declares a custom severity
therefore now compromises the membrane when it fails. sh:Info results are
reported in MembraneResult.infos.
validate_membrane() now returns conforms=True for a report that holds
only sh:Info results, as dry_run() already did. Health is unaffected.
0.7.x → 0.8.0
Security- and correctness-hardening release. The breaking changes below all close audit-confirmed defects; each has a narrow, explicit escape hatch where legacy behaviour is still needed.
Portals no longer widen to the whole dataset (S5)
traverse_portal() and traverse() previously ran a portal’s CONSTRUCT
against every graph in the dataset when the source holon had no
projections. A broad WHERE clause could therefore pull in unrelated
holons’ interiors — a cross-holon PII leak.
Now the CONSTRUCT is scoped to the source holon:
explicit
cga:sourceLayeron the portal wins (cga:DatasetRole→ whole dataset,cga:ProjectionRole/cga:InteriorRole→ that layer of the source);otherwise the source’s projections (the governed view), if any;
otherwise the source’s own interior graphs.
If the source has neither projections nor interiors, traverse_portal()
now raises ValueError instead of silently running against everything.
To restore the old behaviour for a specific call, pass
unscoped_portals_allowed=True, or declare
<portal> cga:sourceLayer cga:DatasetRole on the portal.
Your portal queries do not need rewriting. Scoping changes which graphs a CONSTRUCT can see, not how it is evaluated. The in-scope layers are still presented as named graphs, so the common shape
CONSTRUCT { ... } WHERE { GRAPH ?g { ... } }
keeps working — ?g simply binds to the in-scope graph IRIs instead of
every graph in the dataset. A CONSTRUCT with no GRAPH clause sees the
union of the in-scope graphs, as before.
Sealed-portal enforcement is fail-closed (S6)
The seal check no longer uses a nondeterministic SELECT ?type ... LIMIT 1
plus a substring test. It is now an exact ASK { GRAPH ?g { <portal> a cga:SealedPortal } }. A portal typed as both cga:SealedPortal and some
other portal type is now reliably blocked. If you relied on a dual-typed
portal being traversable, remove the cga:SealedPortal type.
Type terms are validated (S2)
add_holon(holon_type=...) and add_portal(portal_type=...) now raise
ValueError unless the value is a prefixed name (prefix:Local) or a
valid full IRI. Values that previously slipped through the loose
":" in holon_type check (and could inject Turtle) are rejected.
collect_audit_trail(since=...) is validated as an xsd:dateTime
collect_audit_trail splices its timestamp filter into the query as text
rather than as a binding, so since is now checked before it reaches the
engine and emitted through rdflib’s n3(). A value that is not a well-formed
xsd:dateTime raises ValueError:
since must be an ISO-8601 xsd:dateTime (e.g. '2026-01-31T12:00:00Z'), got '2026-01-31'
Previously such a value was interpolated raw. That had two consequences, and the second is the one that affects working code:
A value carrying a quote could close the literal and append arbitrary patterns to the query — the read-path twin of S1.
A merely malformed value produced an ill-typed SPARQL comparison, which matched nothing and returned an empty trail. Callers that passed something unparseable got silence, not an error.
So the migration risk is not code that passes good timestamps — it is code that passed bad ones and treated the empty result as “no activity since then”. Those call sites now raise. The two most likely forms:
previously |
now |
fix |
|---|---|---|
|
|
|
|
|
pass |
Note since="" specifically: the check is since is not None, not a
truthiness test, so an empty string is validated rather than skipped. If you
forward an optional value straight through, normalize it first:
ds.collect_audit_trail(since=raw_since or None)
What is accepted (verified against the implementation): a date and time
separated by T, with optional fractional seconds and an optional timezone
(Z or ±HH:MM). A timezone is not required, so both of these work:
from datetime import UTC, datetime
ds.collect_audit_trail(since=datetime.now(UTC).isoformat()) # aware
ds.collect_audit_trail(since=datetime.now().isoformat()) # naive
Passing datetime.isoformat() is the recommended form for any datetime you
already hold. Bare dates, epoch seconds, and locale-formatted strings are not
accepted.
Backend query bindings take rdflib terms (A1/A2)
The **bindings keyword on backend.query() / construct() / ask() now
binds by explicit rdflib term type instead of guessing from the string:
a value that is already an rdflib
Node(URIRef/Literal/BNode) binds verbatim, andany other Python value binds as a typed
Literal.
So an IRI must be passed as rdflib.URIRef — of any scheme:
from rdflib import URIRef
# 0.7.x: only urn: strings became IRIs; http:// silently bound as a literal
backend.query(q, holon="urn:holon:1") # was OK by accident
backend.query(q, holon="http://example.org/1") # silently matched nothing
# 0.8.0: explicit and scheme-agnostic
backend.query(q, holon=URIRef("urn:holon:1"))
backend.query(q, holon=URIRef("http://example.org/1"))
FusekiBackend previously ignored **bindings entirely (turning a
parameterized lookup into an unfiltered scan); it now honors them via
injection-safe n3() substitution, matching RdflibBackend.
FusekiBackend holds a pooled session; prefer close() / with (C1/P5)
FusekiBackend now dispatches every operation onto a long-lived
worker-thread event loop and reuses one FusekiClient / aiohttp
session for its lifetime. Two consequences:
It now works inside a running event loop (Jupyter, FastAPI/aiohttp handlers). Previously every call raised
RuntimeError: Cannot run the event loop while another loop is running. No code change is required to benefit.It owns a background thread and an open socket pool until released. Close it deterministically when done:
with FusekiBackend("http://localhost:3030", dataset="ds") as be: ... # be.query(...), be.update(...) # session + worker loop torn down on exit # or, without a context manager: be = FusekiBackend("http://localhost:3030", dataset="ds") try: ... finally: be.close()
A
weakref.finalizehandler closes the session best-effort at GC, and the thread is a daemon (so it never blocks interpreter exit), but callingclose()avoids “Unclosed client session” warnings and frees sockets promptly. Calling any method afterclose()raisesRuntimeError.
Fail-closed traversal rollback is delta-based (C2)
traverse(..., fail_on_breach=True) now rolls a breach back by deleting
exactly the triples it injected, rather than replacing the whole target
interior from a pre-injection snapshot. Concurrent writes to the same
interior therefore survive a rollback. Caveat: DELETE DATA cannot
name blank nodes, so a projection that injects blank-node-bearing triples
still requires exclusive access to the target interior for the duration of
a fail_on_breach=True call. If you traverse concurrently into a shared
target with blank-node projections, serialize those calls yourself.
holarchy_summary() health comes from persisted records (P4)
For dashboards, holarchy_summary() no longer re-validates every membrane on
each call. The health_distribution is now built from each holon’s
most-recent persisted ValidationRecord in a single aggregate query. Two
consequences:
A holon that has never been recorded via
record_validation()(directly or through atraverse()/validate_membrane()flow that persists a record) does not appear in any health bucket. On a holarchy with no validation history the distribution is all-zero — where 0.7.x would have re-derived it live.To restore the old semantics (re-run pyshacl for every holon on every call), pass
live_health=True:# 0.8.0: recompute health live, as 0.7.x always did summary = ds.holarchy_summary(live_health=True)
The counts, root count, staleness count, and recent activities are unchanged.
All library exceptions now derive from HolonicError (A1)
A new base class, holonic.HolonicError, sits under every exception the
library raises. You can now catch the whole family at once:
from holonic import HolonicError
try:
ds.traverse(source, target, fail_on_breach=True)
except HolonicError as exc: # membrane breach, sealed portal, backend error...
log.warning("holonic operation failed: %s", exc)
This is additive. Each exception keeps the builtin base it subclassed before 0.8.0, so existing handlers continue to work unchanged:
Exception |
Pre-0.8.0 base |
0.8.0 bases |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
No code change is required unless you want to adopt the broader
except HolonicError catch. The only observable break is for code that
asserted an exact base (e.g. Type.__bases__ == (ValueError,)) or that
registered MembraneBreachError as a non-library error – it is now a
HolonicError.
Projection pipeline steps run against the source graph (A3)
ProjectionStep.apply, ProjectionPipeline.apply, apply_to_graph, and
apply_to_lpg no longer take a backend argument. A CONSTRUCT step now
always runs against the graph it is handed – the previous step’s output –
rather than, when a backend was passed, against the entire backend dataset.
That old behaviour was a bug: apply_pipeline() would fold triples from
other holons into a single holon’s projection, and multi-step pipelines
did not actually chain (each CONSTRUCT re-read the whole dataset).
# 0.7.x -- backend passed through; CONSTRUCT steps hit the whole dataset
result = pipeline.apply(source_graph, backend=ds.backend)
# 0.8.0 -- materialize the source, then apply; steps stay scoped to it
result = pipeline.apply(source_graph)
HolonicDataset.apply_pipeline(holon_iri, pipeline) is unchanged and now
correctly scopes to the named holon’s merged interiors – prefer it over
calling pipeline.apply with a hand-built graph.
New: ProjectionPipeline.to_spec(iri, description=None) converts a
code-built pipeline into a declarative ProjectionPipelineSpec you can
register_pipeline() and later run_projection(). Transform steps must be
registered (via @projection_transform or the holonic.projections
entry-point group); an unregistered inline callable raises ValueError.
HolonicDataset pipeline internals moved to a delegate (AR1/CQ1)
The projection-pipeline registry and execution logic now live on a
holonic._pipelines.PipelineManager reached via ds._pipelines, part of the
0.8.0 decomposition of the HolonicDataset god class. The public methods
(register_pipeline, register_pipeline_ttl, attach_pipeline,
list_pipelines, get_pipeline, run_projection) are unchanged and require
no migration.
Only affected: code reaching into private helpers that were previously
attributes of HolonicDataset – _pipeline_to_ttl, _step_from_node,
_read_pipeline_steps_ordered, and _record_projection_activity. These are
now methods of PipelineManager; call them via ds._pipelines.<name>(...).
HolonicDataset console reads moved to a delegate (AR1/CQ1)
The console-oriented read methods (list_holons_summary, get_holon_detail,
holon_interior_classes, holon_neighborhood, list_portals, get_portal)
now carry their logic on a holonic._console.ConsoleReads delegate reached via
ds._console. The public methods are unchanged thin wrappers and require no
migration; only code that reached into the (private) implementation is
affected. This lifts the presentation-shaped reads out of the core dataset
surface (the audit’s “core -> console layering” concern).
HolonicDataset membrane validation moved to a delegate (AR1/CQ1)
validate_membrane and validate_all now carry their logic on a
holonic._membrane.MembraneValidator reached via ds._membrane. Both public
methods are unchanged thin wrappers and require no migration.
The _no_shapes_report helper became the module-level function
holonic._membrane.no_shapes_report; HolonicDataset._no_shapes_report
remains as a delegating @staticmethod, so existing callers still work.
HolonicDataset provenance moved to a delegate (AR1/CQ1)
The provenance cluster now lives on a holonic._provenance.ProvenanceLog
reached via ds._provenance: record_traversal, record_validation,
collect_audit_trail, portal_traversal_history, get_activity,
last_traversal, derivation_chain, and rollback_traversal. All eight
public methods are unchanged thin wrappers and require no migration.
Only affected: code reaching into the private _build_surface_report, now
ds._provenance._build_surface_report(...).
HolonicDataset portal registry moved to a delegate (AR1/CQ1)
Portal declaration, mutation, and discovery now live on a
holonic._portals.PortalRegistry reached via ds._portals: add_portal,
remove_portal, update_portal, iter_portals_from, find_portals_from,
iter_portals_to, find_portals_to, find_portal, and find_path. All are
unchanged thin wrappers and require no migration.
Note the generator methods (iter_portals_from, iter_portals_to) now return
the delegate’s generator rather than being generator functions themselves.
Iteration, laziness, and results are identical; only
inspect.isgeneratorfunction(ds.iter_portals_from) changes, from True to
False.
HolonicDataset traversal moved to a delegate (AR1/CQ1)
Portal crossing now lives on a holonic._traversal.TraversalEngine reached via
ds._traversal: the public traverse_portal, traverse, traverse_path, and
dry_run remain as unchanged thin wrappers and require no migration.
This cluster moved with no facade wrappers for its privates, because each
was called only from within the cluster itself. Code reaching into any of
_scoped_dataset, _resolve_target_interior, _projection_hash,
_inject_projection, _rollback_injection, _stored_projection_hash,
_store_projection_hash, or _record_traversal_outcome must now go through
ds._traversal.<name>(...). The in-tree test that pinned _scoped_dataset
(the S5 scoping property) was updated accordingly.
_safe_layer_graph deliberately stayed on HolonicDataset: holon writes,
validation, and projections all use it, so it is shared infrastructure rather
than traversal-owned.
0.6.0 → 0.7.0
No breaking changes. All additions are backward-compatible.
collect_audit_trail() signature change
The method now accepts optional keyword arguments (limit,
offset, since, kind). Calling with no arguments returns the
full trail as before. If you were passing positional arguments
(not possible in 0.6.0), this is a no-op.
MembraneResult has a new field
shape_violations: list[ShapeViolation] is added with a default
empty list. Existing code that unpacks or inspects MembraneResult
is unaffected; the new field is additive.
AggregateHolonShape SPARQL constraint removed
The SPARQL constraint that checked whether AggregateHolon interiors
were populated by traversal provenance has been removed. It had two
bugs: pyshacl ignored sh:severity sh:Warning (always reported as
Violation), and it queried the registry graph for provenance that
lives in context graphs (so it could never be satisfied). If you
had code that depended on this constraint firing, it was already
broken; the removal makes the shape’s behavior match its intent.
New public API
classify_sparql(), validate_iri(), get_activity(),
holarchy_summary(), on_traversal(), on_validation(),
ShapeViolation, HolarchySummary. All are new; no existing
API surface is affected.
Notebook execution in test pipeline
pixi run test now runs test-notebooks in addition to
test-unit. If you have notebooks with runtime errors that
previously went undetected, they will now fail the test suite.
0.5.0 → 0.6.0
One breaking change. All other additions are backward-compatible.
Breaking: Portal CONSTRUCT queries default to projection scope
Before (0.5.0): Portal CONSTRUCT queries always ran against the
full dataset (union of all named graphs). A CONSTRUCT like
WHERE { ?s a ex:Employee ; ?p ?o } would match every triple about
employees, including PII in raw interiors.
After (0.6.0): When the source holon has registered projection
graphs (via add_projection()), the CONSTRUCT runs against the
projection graphs only. Projections exist to be the governed,
sanitized view; raw interiors may contain data the portal should
not see.
If you need full-dataset access, add cga:sourceLayer cga:InteriorRole to the portal:
ds.add_portal(
"urn:portal:full-access", source, target, construct_query,
extra_ttl=(
'<urn:portal:full-access> '
'<urn:holonic:ontology:sourceLayer> '
'<urn:holonic:ontology:InteriorRole> .'
),
)
If the source holon has no projections, behavior is unchanged: the CONSTRUCT runs against the full dataset.
0.4.3 → 0.5.0
Three breaking removals. All were deprecated in 0.4.0 with warnings through the entire 0.4.x series.
Removed |
Replacement |
Was deprecated since |
|---|---|---|
|
|
0.4.0 |
|
|
0.4.0 |
|
|
0.4.0 |
Migration steps:
Find and replace
GraphBackendwithHolonicStorein imports and type annotations.Replace
registry_graph=withregistry_iri=in constructor calls.Replace
ds.registry_graphwithds.registry_iriin attribute access.Remove
HOLONIC_SILENCE_DEPRECATION=1from your environment.
New features (no migration required)
Feature |
Description |
|---|---|
|
Assert a functional subtype at creation time |
|
Generator-based lazy iteration with pagination |
|
Generator-based portal iteration with pagination |
|
Generator-based portal iteration with pagination |
|
Existing method now accepts pagination kwargs |
|
Existing method now accepts pagination kwargs |
|
Existing method now accepts pagination kwargs |
|
Batch construction with one metadata refresh at the end |
|
Serialize the entire dataset to TriG, N-Quads, etc. |
|
Serialize a single named graph |
|
JSON-ready serialization (enums converted to strings) |
|
Enables mypy/pyright type checking for downstream consumers |
|
Shows backend type, holon count, and registry IRI |
0.4.2 → 0.4.3
One breaking change. All other additions are backward-compatible.
Breaking: cga:dataClassification is now an ObjectProperty
Before (0.4.2 and earlier):
<urn:holon:x> cga:dataClassification "CUI" .
After (0.4.3):
<urn:holon:x> cga:dataClassification cga:Internal .
The property changed from owl:DatatypeProperty (range xsd:string)
to owl:ObjectProperty (range cga:ClassificationLevel). The CGA
ontology ships five standard individuals: cga:Public, cga:Internal,
cga:PII, cga:Confidential, cga:Restricted) plus government tiers (cga:CUI, cga:Secret, cga:TopSecret).
Migration steps:
Find all triples using string-valued
cga:dataClassificationin your holarchy:SELECT ?holon ?val WHERE { ?holon cga:dataClassification ?val . FILTER(isLiteral(?val)) }
Replace each string literal with the corresponding IRI:
DELETE { GRAPH ?g { ?h cga:dataClassification "CUI" } } INSERT { GRAPH ?g { ?h cga:dataClassification cga:Internal } } WHERE { GRAPH ?g { ?h cga:dataClassification "CUI" } }
If you used custom classification values not in the shipped enumeration, declare your own individuals:
ex:FOUO a cga:ClassificationLevel ; rdfs:label "FOUO" .
New shapes (no migration required)
Shape |
Targets |
Severity |
What it checks |
|---|---|---|---|
|
|
Info |
Should have interior, boundary, and context layers |
|
|
Warning |
Interior data without traversal provenance |
0.4.1 → 0.4.2
Zero required changes. All additions are backward-compatible.
Behavioral changes in portal discovery
Two latent bugs were fixed. Both are behavioral changes rather than API changes, so no code needs to move, but downstream consumers should know about them.
find_portals_from/to/direct no longer return duplicates.
Pre-0.4.2 queries matched portal triples in every graph where the
portal appeared, so each portal came back twice.
find_portals_from/to/direct now return all portal subtypes,
not just cga:TransformPortal. Pre-0.4.2 queries hardcoded a
type filter that silently omitted non-TransformPortal subtypes.
0.3.x → 0.4.0
0.4.0 was the first release labeled as breaking. The changes are small in code volume, large in naming.
Summary
Change |
Scope |
|---|---|
|
Imports, type annotations |
|
|
|
Every |
The GraphBackend and registry_graph aliases were kept through
0.4.x with deprecation warnings, then removed in 0.5.0 (see above).
The FusekiBackend positional form was removed immediately in 0.4.0
with no compatibility shim.
Getting help
Questions on the library’s design decisions:
docs/DECISIONS.mdSpecification:
docs/SPEC.mdBug reports and migration issues: file on the holonic GitHub repository.