Feature/x ledger policy#1246
Open
bplatz wants to merge 50 commits into
Open
Conversation
resolve_policy_graphs_from_config previously logged a warning and fell back to graph 0 on both config-load failure and policy-source resolution error. That behavior (a) neutered the fail-fast cross-ledger and unsupported-field rejection in policy_builder::resolve_policy_source_g_ids and (b) silently enforced default-graph policy rules under a config that deliberately pointed elsewhere. Now the function returns Result and propagates both error paths. The Ok(None) case from resolve_ledger_config still returns [0], because a fresh ledger with no config written yet is the normal "no special policy graph configured" state, not a failure. The three call sites (wrap_policy_view, wrap_policy_view_historical, build_policy_context) already returned Result, so propagation is just .await?. Two regression tests cover the unknown-graph and cross-ledger configurations through build_policy_context, which is the path used by server transact handlers and the CLI insert command.
…ed fields resolve_constraint_source_g_ids previously skipped unknown graph selectors silently (the corresponding uniqueness constraint just stopped being enforced) and ignored the cross-ledger / temporal / trust / rollback fields of f:GraphRef. Both behaviors are security regressions: an operator who configures a constraint source must not get less enforcement than they asked for. The resolver now returns Result and rejects unsupported fields and unknown selectors with TransactError::Parse, matching the shapesSource and policySource resolvers. The single caller propagates via ?. Two regression tests cover an unknown-graph selector and a config that sets cross-ledger f:ledger — both fail the transaction with a clear diagnostic.
Specifies the contract for resolving f:GraphRef cross-ledger so that a
single model ledger can hold the ontology, SHACL shapes, policy rules,
datalog rules, and uniqueness constraints that govern many data
ledgers.
Load-bearing decisions captured:
- A single resolve_graph_ref helper, shared by all five subsystems,
returns term-neutral, model-t-fixed artifacts. The data-ledger
side re-interns IRIs at use; namespace codes, graph ids, and t
values from the model never leak into data-ledger execution.
- The cache is keyed only on (canonical_model_ledger_id, graph_iri,
resolved_t). It is shareable across every data ledger that
references the same model, which is what makes "model edit
propagates atomically" cheap.
- The policy IR distinguishes definitional vs contextual term
binding. Identity and authentication flow only from the data
ledger and request context; the model ledger contributes rules,
never identities. Trust is one-directional.
- resolved_t is captured once per request and reused across every
subsystem call, so policy and shapes can never disagree about
which version of the model they're enforcing.
- Failure variants are distinct and all fail closed:
ModelLedgerMissing, GraphMissingAtT, TAtUnavailable,
ReservedGraphSelected, TranslationFailed, TrustCheckFailed,
CrossInstanceUnsupported, CycleDetected. No silent fallback to
"no policy" or "no shapes."
- Cycle detection is on the resolved (ledger, graph_iri,
resolved_t) tuple, not just (ledger, graph_iri).
- Drop interaction (v1): dropping a referenced model ledger is
allowed; the next governed request fails closed with
ModelLedgerMissing. No reverse-reference index.
- Cross-instance federation, f:trustPolicy / f:rollbackGuard
implementations, auto-resolution by IRI namespace, and cross-
ledger writes are out of scope.
Includes phasing (Phase 0 same-ledger fail-closed already largely
landed; rules wiring deferred behind this design), mandatory
cross-cutting tests (distinct-namespace-codes canary, single-
resolution-t), and open questions for review.
SUMMARY.md and design/README.md updated.
Three design questions in the prior open-questions section are now decided: resolved_t capture is lazy per request, keyed by canonical model ledger id. The head of M is read on the first unpinned reference to M within a request and reused for every subsequent unpinned reference. Requests that never need M never pay for M's head lookup. f:atT pins resolve per-call and do not populate the map. This replaces the prior "captured at admission" language, which forced an eager M.head() read on every request whether or not any governance artifact ended up consulting M. Cycle detection separates the active resolution stack from the per-request completed memo. A tuple is a cycle only when re- entering an in-flight resolution; if policySource and shapesSource reference the same (ledger, graph, t), the second resolve is a memo hit, not a cycle. The earlier "checked against ctx.seen" wording conflated cross-subsystem de-dup with cycle detection. CrossLedgerError surfaces through a dedicated ApiError::CrossLedger variant; it is not collapsed into ApiError::Http(502, ...). The structured variant preserves the specific failure (missing ledger, graph missing at t, retention pruned, reserved graph, translation failure, trust failure, cross-instance, cycle) for audit and operator diagnostics. HTTP status maps to 502 by default — model- ledger dependency failure is an upstream-style condition, not an internal panic. 424 Failed Dependency is semantically closer but less commonly handled by client tooling, so 502 is the pragmatic choice; the response body carries the variant for callers that want to branch on it. ResolveCtx now holds three fields beyond the data-ledger id and Fluree handle: resolved_ts (HashMap<String, i64>), active (Vec<(String, String, i64)>), and memo (HashMap<(String, String, i64), Arc<ResolvedGraph>>).
Two clarifications. Identity-mode under cross-ledger: cross-ledger policy resolution uses policy_class mode only. The data ledger's configured / opts-provided policy class IRIs are looked up in M to load the corresponding policy rules; the request identity is bound to ?$identity at evaluation time against D and the request context. We do not query <identity> f:policyClass on M, because that would either return empty (M has no entry for D's user) or — worse — match a model-ledger identity that happens to share an IRI with D's user, attributing policy silently to the wrong actor. Inline opts.policy JSON-LD continues to merge against D, never against M. Cache location: governance artifacts cache at the API layer, not in fluree-db-binary-index::LeafletCache. The binary-index crate sits below fluree-db-api, fluree-db-policy, and the cross-ledger module, so making it depend upward on typed governance-artifact representations would be a layering inversion. Phase 1a uses a Moka cache bounded by entry count, scoped to a Fluree instance. Single memory-pool unification with LeafletCache (via an opaque-blob variant in the binary-index cache) is a follow-up, deferred until the artifact representation stabilizes.
…slator Introduces a term-neutral wire representation of a policy artifact — IRIs throughout, no Sids — that the cross-ledger resolver will produce and the per-instance governance-artifact cache will store. A single (canonical_model_ledger_id, graph_iri, resolved_t) entry is shareable across every data ledger that references the same model graph at the same model-t. build_policy_set_from_wire translates a wire artifact into a Sid-form PolicySet against a data ledger's term space and delegates to the existing build_policy_set. IRI resolution is supplied via a Fn(&str) -> Option<Sid> closure so fluree-db-policy stays free of LedgerSnapshot references; production wraps LedgerSnapshot::encode_iri. The closure also keeps the unit tests snapshot-free. Five tests cover the translation contract: empty wire, property target indexed under translated SID, partial resolution preserves the restriction with unresolvable IRIs dropped, behavior parity with the direct same-ledger PolicyRestriction construction path (restrictions_for_flake decisions match for matching and non-matching flakes), and Query JSON pass-through. Tests assert behavior, not byte-equality — HashSet ordering and incidental diagnostic strings are not load-bearing. No production callers yet; the same-ledger materializer in policy_builder.rs is untouched. Slice 4 will add the cross-ledger producer; slice 5 wires it through policy_view.rs.
…pping Introduces the cross_ledger module with the structured error type the resolver will surface. Every variant is fail-closed; the design doc forbids any silent fallback when a cross-ledger dependency cannot be served, and the type system now enforces that callers either propagate or handle each variant explicitly. Variants mirror the failure modes spec'd in the design doc: ModelLedgerMissing — named ledger absent on this instance. GraphMissingAtT — graph IRI not in model registry at t. TAtUnavailable — f:atT pinned past retention. ReservedGraphSelected — selector resolves to #config/#txn-meta. TranslationFailed — read succeeded but projection didn't. TrustCheckFailed — f:trustPolicy or f:rollbackGuard (Phase 4). CrossInstanceUnsupported — f:ledger targets a different instance. CycleDetected — (ledger, graph, t) re-entered in chain. CycleDetected carries the active resolution chain so operators can see the full path that closed the loop, rendered in display order. ApiError gains a CrossLedger(#[from] CrossLedgerError) arm. The status_code mapping uses 502 across all variants: a model-ledger dependency that cannot be resolved is conceptually an upstream- dependency failure, not an internal panic. 424 Failed Dependency is semantically closer but less commonly handled by client tooling; 502 is the pragmatic choice. The wrapped variant is preserved in the response body so callers can branch on the specific failure. is_not_found() stays false for cross-ledger variants — surfacing a missing model ledger as 404 would mislead clients into treating their target resource as deleted. Four tests: display chain rendering for CycleDetected, coordinate inclusion for GraphMissingAtT, and two contract tests covering (a) every variant lifts via From and maps to 502, and (b) cross- ledger variants are not classified as not-found. No resolver call sites yet. The error type lands first so the resolver skeleton in the next slice can be born returning it.
…ializer
The orchestration skeleton for cross-ledger model enforcement. The
resolver API is born with the full lifetime / consistency model
described in the design doc — resolved_ts, active, and memo are
baked into ResolveCtx from day one so the API is correct before
artifact materialization lands in slice 4.
types.rs defines:
ArtifactKind — PolicyRules now; others land per phase.
ResolvedGraph — (canonical_model_ledger_id, graph_iri,
resolved_t, artifact).
GovernanceArtifact — tagged union over PolicyArtifactWire.
ResolveCtx — data ledger id + Fluree handle +
resolved_ts (lazy per-model head-t map) +
active (cycle stack) + memo (per-request
completed). Pinned f:atT does NOT populate
resolved_ts; only unpinned captures.
types.rs also exposes three pure helpers that the resolver
composes, kept separate so they are unit-testable in isolation
without spinning up a Fluree:
reject_if_reserved_graph — config / txn-meta selectors fail before
any storage round-trip on M.
memo_hit — per-request completed lookup; shared Arc.
check_cycle — active-stack reentry detection; on hit,
returns the chain with the offending
tuple appended for diagnostic display.
resolver.rs::resolve_graph_ref performs, in order:
1. Reject Phase-4 fields (f:trustPolicy / f:rollbackGuard) with
a clear "not yet supported" message.
2. Require f:ledger and f:graphSelector; refuse same-ledger refs
(call-site bug — those use the local resolver).
3. nameservice.lookup → ModelLedgerMissing on absent / retracted.
Same-instance is enforced implicitly by the lookup boundary.
4. Reserved-graph guard against the canonical id (an alias can't
slip a #config through by spelling).
5. resolved_t — f:atT pin if set, else lazy capture from
ctx.resolved_ts, else read NsRecord.commit_t once and store.
6. Memo hit before active check, so cross-subsystem de-dup is
never seen as a cycle.
7. Push active, materialize, pop active (always — even on error,
so a deeper failure doesn't poison subsequent calls), insert
into memo on success.
materialize() is the stub dispatch — every kind returns
TranslationFailed { detail: "...not yet implemented..." }. Slice 4
fills in PolicyRules.
Thirteen tests cover the pure helpers (reserved-graph guard for
config, txn-meta, and unrelated graphs; cycle detection for
unique tuples, reentry, and distinct atT pins of the same graph;
memo Arc-cloning) and the resolver shell (same-ledger call-site
bug surfaces as TranslationFailed; unknown ledger surfaces as
ModelLedgerMissing through an in-memory Fluree; trust_policy is
rejected until Phase 4).
…resolved_t
The resolver now actually materializes — reads a model ledger's
policy graph at resolved_t and returns a term-neutral
PolicyArtifactWire. The Phase 1a contract is intentionally narrow so
the cache key stays free of per-data-ledger context:
Wire materializer loads only subjects with rdf:type f:AccessPolicy
directly. Subclass entailment (rdfs:subClassOf chains rooted at
f:AccessPolicy) is NOT applied — policies in the model ledger must
declare f:AccessPolicy directly. The cache key (slice 7) stays
(canonical_model_ledger_id, graph_iri, resolved_t) with no
policy_class fragmentation, so a single wire artifact is shareable
across every data ledger that references the same model graph.
policy_materializer.rs reuses the same-ledger
policy_builder::load_policies_by_class (bumped to pub(crate))
against M's snapshot/overlay/t — the per-policy predicate fan-out,
target-mode determination, and action filtering are exactly the
proven same-ledger path. The translation step then decodes each Sid
in the resulting PolicyRestriction list back to an IRI via
M.snapshot.decode_sid. A Sid that fails to decode surfaces as
CrossLedgerError::TranslationFailed (with the offending field
named) rather than being silently dropped — silent drop would
produce a policy structurally weaker than what was authored, the
exact failure mode the design doc forbids.
The resolver's materialize() dispatch is wired to call into the
materializer for ArtifactKind::PolicyRules, and the dead-code guard
from slice 3 is removed.
Integration tests in a new tests/it_cross_ledger_resolver.rs set the
two-ledger pattern that later slices will reuse:
resolves_policy_graph_from_model_ledger_into_term_neutral_wire —
M holds one ex:User-targeting Deny policy in a named graph; D
is unrelated. resolve_graph_ref returns a wire artifact whose
origin points at M (not D), whose single restriction carries
the expanded ex:User IRI in for_classes (not a curie, not a
Sid), and whose value is WirePolicyValue::Deny. A second
resolve in the same context is verified to be an Arc-equal
memo hit, not a fresh materialization.
unknown_graph_on_model_ledger_surfaces_graph_missing_at_t —
Distinguishes missing-graph from translation failure;
GraphMissingAtT names the model ledger and graph IRI for audit.
empty_policy_graph_yields_empty_wire_artifact —
Graph exists in M but has no f:AccessPolicy subjects.
Wire artifact has zero restrictions; not an error.
Slice 5 wires this through policy_view.rs so it actually enforces
against queries against D.
…arries policy_types
Reworks the slice-4 materializer to match the cache-stays-clean
contract the user picked: the wire artifact stores every
policy-looking subject in the graph regardless of its rdf:type, and
the data ledger's configured f:policyClass set gets intersected
against the rule's recorded rdf:type list at translation time
(slice 5). The cache key remains free of policy-class context, so
one wire artifact is shareable across every data ledger that
references the same model graph.
WireRestriction grows a policy_types: Vec<String> field carrying
every rdf:type IRI declared on the rule subject. Same-ledger
behavior is unchanged — the build_policy_set_from_wire translator
will use this field in slice 5 for exact-IRI intersection
(subclass entailment stays out of scope; it matches the existing
same-ledger load_policies_by_class semantics, which also require
direct rdf:type matching).
Structural detection in the materializer: a subject is treated as
a policy if it has at least one f:allow or f:query triple in the
configured graph (the two effect predicates that uniquely
characterize a policy resource — f:action without an effect would
be a malformed declaration). The same-ledger
load_policy_restriction (bumped to pub(crate)) handles the per-
policy predicate fan-out against M's snapshot; the materializer
adds an rdf:type SPOT scan per subject to populate policy_types.
A subject whose load_policy_restriction returns None (the same-
ledger "malformed" signal — has the value predicate but action /
target combination doesn't add up) is silently skipped, matching
the existing local behavior. Diverging cross-ledger from local on
malformed inputs would just create a different surface for the
same correctness bug.
Two new integration tests pin the structural-detection contract:
structural_detection_picks_up_custom_typed_policies — A policy
typed only as ex:OrgPolicy (NOT f:AccessPolicy) is still
materialized. policy_types records the ex:OrgPolicy IRI so
slice 5 can recognize it under the data ledger's policy_class
config. The previous slice-4 hardcoded f:AccessPolicy would
have silently dropped this policy.
multiple_rdf_types_are_all_captured_in_policy_types — A policy
declaring rdf:type both f:AccessPolicy and ex:OrgPolicy
surfaces both IRIs in policy_types. A data ledger that
configures either class can select this rule via set
intersection at translation time.
Plus the four slice-1 wire-form tests are updated to provide the
new policy_types field (f:AccessPolicy IRI in each, matching what
the production materializer would emit for canonically-typed
policies). The original "resolves_policy_graph_from_model_ledger"
test also asserts policy_types contents.
policy_builder::load_policies_by_class stays at pub(crate) — no
longer used by the materializer but exposed for tests and possible
future code paths. Not removed because the visibility bump was
trivial and the next slice may want it.
…s on data ledger
Wires the cross-ledger resolver into the live policy-wrap path. A
data ledger D whose #config declares f:policySource with f:ledger
set is routed through resolve_graph_ref to a model ledger M; M's
materialized policy rules are translated into D's term space and
applied to every query under db_with_policy.
fluree_db_policy gains wire_to_restrictions — the Vec<PolicyRestriction>
flavor of the translator that build_policy_set_from_wire is now a
thin wrapper over. The new entry point takes an Option<&HashSet<String>>
policy_class filter performing exact-IRI intersection against each
restriction's policy_types. Two new unit tests cover the filter
behavior: matching / non-matching / empty-set cases, plus the
explicit drop of untyped (empty policy_types) restrictions when
any filter is supplied.
fluree_db_api::policy_builder gains
build_policy_context_from_opts_with_cross_ledger — a variant that
accepts a pre-materialized Vec<PolicyRestriction> and short-
circuits the same-ledger load_policies_by_class / parse_inline_policy
path. Identity resolution still runs locally against D so ?$identity
binds against the data ledger's term space exactly as in same-ledger
mode. Inline opts.policy is merged on top of the cross-ledger
restrictions; identity-mode loading is unchanged.
fluree_ext::wrap_policy detects cross-ledger configs and dispatches:
1. Read resolved_config.policy.policy_source; cross-ledger iff
source.ledger.is_some().
2. Phase 1a fail-closed: combining opts.identity with cross-ledger
f:policySource is rejected with ApiError::Config. Identity-mode
attributes policies via the identity's local f:policyClass; that
binding inherently belongs to D. Mixing it with a remote rule
source produces ambiguous attribution, so it's blocked until a
later phase teaches the IR which class IRIs to forward.
3. Build ResolveCtx scoped to D's canonical id, call
resolve_graph_ref for ArtifactKind::PolicyRules, unpack the
PolicyArtifactWire from the returned GovernanceArtifact.
4. Translate via wire_to_restrictions with effective_opts.policy_class
as the filter set (or None when no class is configured). The
intersection is exact-IRI — the same semantics today's
load_policies_by_class enforces against an rdf:type triple.
5. Pass into build_policy_context_from_opts_with_cross_ledger.
Same-ledger configs (source.ledger.is_none()) go through the
unchanged local path; behavior is byte-identical for every existing
policy test.
Two end-to-end tests in tests/it_policy_cross_ledger.rs prove the
contract works:
data_ledger_query_enforces_model_ledger_deny_policy —
M holds rdf:type f:AccessPolicy with f:onClass ex:User and
f:allow false in a named graph. D's #config declares
f:defaultAllow false, f:policyClass f:AccessPolicy, and
f:policySource pointing at M's named graph via f:ledger. D
contains ex:alice typed as ex:User and ex:doc1 typed as ex:Doc.
A query for ?u rdf:type ex:User against D under db_with_policy
returns empty (M's deny enforces). A query for ?d rdf:type
ex:Doc also returns empty (defaultAllow=false with only the
deny rule means no class is allowed) — confirming D's results
come exclusively from M's policies, with no silent fall-through
to default-graph rules.
cross_ledger_plus_identity_mode_fails_closed —
db_with_policy with both opts.identity set and cross-ledger
f:policySource returns ApiError::Config whose message names
both "identity" and "cross-ledger" for operator clarity.
Regression: it_policy_allow (9), it_policy_class (3),
it_policy_named_graphs (1), and it_config_graph (30) all still pass
unchanged — the cross-ledger branch is only entered when
source.ledger.is_some().
Three integration tests in it_policy_cross_ledger.rs that nail down
how the cross-ledger wire→PolicySet filter resolves D's configured
f:policyClass against each wire restriction's policy_types. Exact
IRI matching, no subclass entailment — both sides of the
intersection are asserted at the enforcement layer (db_with_policy
+ query), not just at the wire layer.
custom_class_policy_enforced_when_data_ledger_includes_class
M holds a policy typed exactly as ex:OrgPolicy (NOT
f:AccessPolicy) with f:onClass ex:User f:allow false. D's
config names ex:OrgPolicy verbatim under f:policyClass. With
defaultAllow=true so the deny is the *only* thing that can
hide alice, the query for ?u rdf:type ex:User returns empty —
proving the custom class IRI flows through the filter
intersection.
custom_class_policy_skipped_when_data_ledger_omits_class
Same M policy (ex:OrgPolicy-typed). D's config names
f:AccessPolicy under f:policyClass — a *different* IRI, even
though both are policy classes in spirit. defaultAllow=true.
Alice MUST remain visible: the wire restriction's policy_types
is [ex:OrgPolicy], the filter set is {f:AccessPolicy}, the
intersection is empty, the restriction is dropped at
translation time, the deny never reaches enforcement. If the
filter were absent, subsumptive, or order-sensitive, alice
would be hidden — and this test would fail.
baseline_access_policy_class_enforced
The canonical case: M's policy typed directly as f:AccessPolicy,
D's config naming f:AccessPolicy. Confirms the default /
baseline configuration still works the way it would in a
same-ledger setup. Uses defaultAllow=true and a deny-on-class
query so the assertion isolates "rule applied" from "default
policy denied everything."
Existing data_ledger_query_enforces_model_ledger_deny_policy and
cross_ledger_plus_identity_mode_fails_closed are untouched. The
five-test suite now pins:
- Enforcement under matching class config.
- Non-enforcement under mismatched class config (exact match
semantics, not subsumption).
- Baseline behavior under the canonical f:AccessPolicy class.
- The deny-flow + defaultAllow=false fall-through case.
- The fail-closed boundary for opts.identity + cross-ledger.
f:atT was being honored at face value — the resolver took graph_ref.at_t and passed it through to db_at_t(M, at_t), giving partial temporal behavior with no retention guard. That violates the design doc's "no silent degradation" posture and the "unsupported fields fail closed" stance applied to the rest of the GraphRef shape. Adds a CrossLedgerError::UnsupportedFeature variant that names the field, the phase it lands in, and the ledger reference it appeared on. f:atT (Phase 3), f:trustPolicy (Phase 4), and f:rollbackGuard (Phase 4) all route through this single variant — previously trust_policy / rollback_guard were piggybacking on TrustCheckFailed with a "not yet supported" string in detail, which was a stretch. TrustCheckFailed now keeps its semantic identity (an actual trust check failing under a Phase-4 policy) and isn't muddled with the "feature deferred" case. The resolved_t branch that handled at_t is removed since at_t can no longer reach it; the lazy per-request capture is now the only shape. The unreachable code wasn't load-bearing for memo/cycle semantics but its presence implied a contract the resolver doesn't honor. Three tests pin the rejection one variant at a time. The existing "trust_policy_field_is_rejected_until_phase_4" was updated to assert on the new variant shape, and parallel tests cover f:atT and f:rollbackGuard so the contract surface doesn't drift between fields. The variant-completeness test that lifts every variant through ApiError::from also gains an UnsupportedFeature entry, so adding a future variant without updating the HTTP-502 mapping trips the test rather than slipping through.
…class is set
When effective_opts.policy_class was empty or unset, wrap_policy
passed None to wire_to_restrictions, which meant "include every
restriction in the wire artifact" — including custom-typed and
untyped rules. That contradicted the design contract: an operator
who configures f:policySource cross-ledger but doesn't name a
specific policy_class should get baseline enforcement, not a
wholesale pull of every rule M happens to publish.
The new default is {f:AccessPolicy} — the canonical policy class.
Custom-typed rules (e.g., ex:OrgPolicy) require an explicit
f:policyClass entry in D's config to be enforced. Two behaviors
fall out:
- Operators get baseline enforcement out of the box. Declaring
f:policySource → M with no policyClass pulls in every
f:AccessPolicy-typed rule M publishes.
- Custom-class rules are explicit opt-in. M can publish whatever
typed rules it wants; D doesn't pick them up by accident.
The previous "loads no rules from M" wording in the design doc is
updated to reflect the new default. That wording was both
internally inconsistent with the implementation and weaker than
necessary — silent zero enforcement under a configured
f:policySource is a worse default than "f:AccessPolicy baseline."
omitted_policy_class_defaults_to_access_policy_only pins both
sides: M holds a canonical f:AccessPolicy rule on ex:User and a
custom ex:OrgPolicy rule on ex:Doc, D's config omits f:policyClass
entirely. The User query returns empty (canonical rule enforced via
default filter); the Doc query returns ex:doc1 (custom-typed rule
filtered out, defaultAllow=true governs). Together with the
existing custom-class enforce/skip tests, the filter contract is
now pinned across explicit-include, explicit-exclude, and default
configurations.
…oth effects Same-ledger load_policy_restriction treats a policy subject with neither f:allow nor f:query as PolicyValue::Deny (with a warning). Cross-ledger structural detection scanned only POST(f:allow) and POST(f:query), so a canonical-typed policy resource that was missing both effects would silently disappear cross-ledger while still being enforced as Deny same-ledger — exactly the kind of contract divergence the design doc forbids. policy_materializer now unions in subjects matching POST(rdf:type, f:AccessPolicy). The canonical class is the only type-driven baseline; custom-typed policies (e.g., ex:OrgPolicy) without f:allow / f:query are still missed cross-ledger, mirroring the limit on the class filter default. That asymmetry is documented inline. A new resolver-level integration test pins the contract: an ex:incompleteDeny resource typed exactly as f:AccessPolicy, with f:onClass ex:User and f:action f:view but no f:allow / f:query, is surfaced as a wire restriction with value Deny. Without the rdf:type scan it would have been silently dropped.
…t collide
The per-request memo map and active resolution stack were keyed on
(canonical_model_ledger_id, graph_iri, resolved_t). That was safe
while PolicyRules was the only ArtifactKind, but the shared
resolver returns wrong-typed entries the moment a second variant
(SchemaClosure / Shapes / DatalogRules / Constraints) starts using
the same (M, graph, t) tuple — a future PolicyRules → Shapes
collision would either deliver the wrong GovernanceArtifact or
trip a phantom cycle.
ArtifactKind derives Hash and Display; a ResolutionKey type alias
ResolutionKey = (ArtifactKind, String, String, i64) is the new
shape of both memo keys and active-stack entries. CycleDetected's
chain carries the kind too, and chain_display renders it as
"PolicyRules(ledger#graph@t=N)" so operators can see in audit
which subsystem closed the loop.
resolve_graph_ref constructs the key with ArtifactKind first, then
the existing canonical (ledger, graph, t). The memo / cycle check
both consume the new shape. No behavior change while Phase 1a only
has PolicyRules, but the contract surface is now correct for the
phasing roadmap.
Test additions / updates:
cycle_check_treats_different_artifact_kinds_on_same_graph_as_distinct
Pins that two resolutions for the same (ledger, graph, t) but
different kinds are NOT a cycle. (Currently uses different t
pins since PolicyRules is the only variant; Phase 1b should
replace one of them with SchemaClosure when that lands, so the
contract is exercised across kinds.)
cycle_display_renders_chain_in_order — renamed to
cycle_display_renders_chain_in_order_with_artifact_kind and
asserts the new "PolicyRules(...)" rendering.
Existing tests (cycle_check_passes_for_unique_tuples /
cycle_check_fails_on_reentry / different_t_pins /
memo_returns_cloned_arc) constructed 3-tuples; switched to use a
small `key()` helper that prepends ArtifactKind::PolicyRules so
the test surface mirrors the production shape.
Design doc updates: ResolveCtx fields and the cache-section key
description now name ArtifactKind explicitly. Three references in
the doc to the 3-tuple form were updated.
Three pockets of doc drift after the review fixes: resolver.rs module + function header still described the slice-3 shape — said `f:trustPolicy`/`f:rollbackGuard` "absent" rather than "rejected as UnsupportedFeature," and said `f:atT` was honored as a pin in step 4 even though it's now rejected at step 1. The step list now names the actual order of operations: explicit Phase-3 / Phase-4 rejection, canonicalization, reserved-graph guard, lazy resolved_t capture (with the pin rejection cross-referenced), key construction including ArtifactKind, memo hit before active check, materialize, pop active on both success and failure. types.rs::ResolvedGraph said it was "Cached at the API layer by (model_ledger_id, graph_iri, resolved_t)" — that's the pre-Medium-2 shape. Now reads "(ArtifactKind, model_ledger_id, graph_iri, resolved_t)" with a pointer to the ResolutionKey alias. types.rs::GovernanceArtifact said the memo was "keyed only on (ledger, graph, t)" — same drift; updated to point at ResolutionKey. types.rs::ResolveCtx.resolved_ts said "Pinned f:atT does NOT populate this map; pinned values are per-resolve" — true at slice 3 when pins were accepted, no longer accurate. New wording names the UnsupportedFeature rejection upstream so future readers don't go looking for the pin-handling code. No behavior change; comments only.
…requests
Slice 4's per-request memo on ResolveCtx covered intra-request
dedup but did nothing across requests — every new request
re-materialized the same model ledger graph, defeating the
"one cache entry serves every data ledger that references the
same (M, graph, t)" property the design doc commits to.
cross_ledger::cache::GovernanceCache wraps a moka::sync::Cache
over (ResolutionKey, Arc<ResolvedGraph>). Phase 1a is bounded by
entry count (default 4096) rather than the byte-budget unification
with LeafletCache — that unification is held back until the
artifact representation stabilizes and an opaque-blob variant gets
added to fluree-db-binary-index. Pushing typed governance
artifacts into LeafletCache today would be a layering inversion
(fluree-db-binary-index sits below fluree-db-api,
fluree-db-policy, and the cross-ledger module).
Fluree gains a governance_cache: Arc<GovernanceCache> field
initialized in all three constructor paths (from_backend,
with_indexing_mode, the builder's from_runtime_parts) plus a
public accessor.
The resolver now checks the cache between memo miss and
materialization, and writes back to both layers on success.
Concretely:
1. memo hit (intra-request dedup) → return.
2. governance_cache hit → fold into memo and return; subsequent
resolutions in this same request short-circuit at (1).
3. miss → cycle check, materialize, push to both memo and
governance_cache.
The resolver step list in the function-level doc is rewritten to
name (5a) and (5b) so future readers can see the two-layer cache
order at a glance.
governance_cache_short_circuits_repeated_resolutions_across_contexts
proves the contract: three independent ResolveCtxs (one per
data ledger D-A / D-B / D-C, all on the same Fluree instance,
all pointing at the same (M, policy_graph, t)) return Arc-equal
ResolvedGraphs. The first context materializes; the next two
return the cached Arc unchanged. Without the cache the second
context would have constructed a fresh Arc and the Arc::ptr_eq
assertion would fail.
No invalidation channel. New commits to M produce new resolved_t
values and therefore new keys; old entries age out under TinyLFU.
Operators who need explicit eviction will get it when the
byte-budget unification lands or via a future admin endpoint.
The design doc's two mandatory cross-cutting tests, both resolver-level integration tests that exercise contract surfaces which the per-feature tests don't isolate cleanly. distinct_namespace_codes_canary_term_translation_still_works Verifies that term translation is genuinely IRI-driven, not Sid-passthrough. M holds a deny-on-class policy targeting ex:User. D is seeded with an unrelated namespace (http://unrelated.example.org/v1/) BEFORE its ex:-prefixed data lands, so D's ns_code for http://example.org/ns/ ends up at a different position than M's. The test asserts the codes actually diverge (printing a warning if they happen to match, in case a future ns-allocation change aligns them), then runs the end-to-end enforcement and confirms alice is denied. If the wire form ever leaked M's Sids into D's evaluation, the deny would silently miss and the test fails. single_resolution_t_is_stable_within_a_request Verifies the lazy governance-context capture is captured ONCE per request and reused. M holds two policy graphs (A and B). A single ResolveCtx resolves graph A → captures M's head_t. An unrelated commit advances M between resolutions. The second resolve against graph B must reuse the captured t, NOT pick up the new head. The test asserts resolved_b.t == resolved_a.t, confirms ctx.resolved_ts has exactly one entry for M holding that captured t, then opens a fresh ResolveCtx and confirms it DOES see the advanced head — proving the cache is request-scoped, not instance-scoped. Without single-resolution-t, policy and shapes could enforce against different versions of M for the same request. Both tests are pure resolver-level; they don't depend on the db_with_policy → query path. That isolation is intentional — they're testing the resolver's contract surface, not the end-to-end policy enforcement (which has its own e2e tests).
The distinct-namespace-codes canary previously fell back to an eprintln warning if M and D happened to allocate the same ns_code for ex:User, then continued running the end-to-end assertion. That let CI report the canary green while never actually exercising the cross-namespace-code path it exists to defend — a regression where Sids leaked from M into D could slip through unnoticed whenever the warning case triggered. The check is now a hard assert_ne!. The seed order in the test (M's ex: prefix gets allocated as its first user namespace, D's ex: gets allocated AFTER an unrelated "http://unrelated.example.org/v1/" filler) is constructed specifically to force divergence: if a future change to ns_code allocation ever aligns them, the test fails with a message pointing at the seeding rather than passing quietly. That's the right tradeoff for an acceptance test the design doc names as cross-cutting.
ApiError::status_code() returned 502 for ApiError::CrossLedger since slice 2, but fluree-db-server's ServerError::status_code() had no CrossLedger arm — the variant fell through to the 500 catch-all and out the door as INTERNAL_SERVER_ERROR. error_type() had the same gap, mapping to err:system/InternalError. The HTTP contract the design doc commits to (and the api-crate's status- code unit test verifies) was effectively shadowed at the route layer. Both arms are now explicit. fluree_vocab::errors::CROSS_LEDGER is a new "err:system/CrossLedgerError" constant; ServerError matches ApiError::CrossLedger to it for the JSON body's @type and to StatusCode::BAD_GATEWAY for the HTTP status. The wrapped CrossLedgerError variant is preserved by ApiError::CrossLedger's Display so the body's `error` field carries the variant-specific message (ledger id, graph IRI, phase tag, etc.) for operators who branch on specific failures rather than the umbrella code. Slice 8 — a new fluree-db-server integration test (cross_ledger_http_integration.rs) closes the loop end-to-end: query_under_cross_ledger_config_to_missing_model_returns_502 Creates a data ledger D via the HTTP /v1/fluree/create route, seeds D's data plus a cross-ledger #config (in one TriG upsert) pointing at a model ledger ID that is intentionally never created. A query against D with a fluree-policy-class header engaging the policy path returns HTTP 502 with body asserting `status:502`, `@type: err:system/CrossLedgerError`, and the missing-model id named in the `error` message. A regression where the server's CrossLedger arm gets dropped (status falls through to 500) trips this test, and a regression where the variant is collapsed to a generic Display loses the model_id naming check. The test uses `fluree-policy-class: https://ns.flur.ee/db#AccessPolicy` to engage the policy path because the JSON-LD query route only takes db_with_policy when has_policy_opts() sees identity / policy-class / policy in the request opts. Whether config-only policy (no request opts) should auto-engage the cross-ledger dispatch is a separate concern from this slice — flagged in the test's comments for follow-up.
The cross-ledger design doc had drifted into a plan document
through incremental edits during implementation. Several spots
named phase numbers as if they were a roadmap, the ResolveCtx
signature speculated <S, N> generics on Fluree that the concrete
type doesn't have, and a Test plan section duplicated information
that lives next to the tests. The framing made it harder to use
the doc as a reference for what the system actually does and why.
Reshaped to read as a design-decisions document:
- Header drops the "Status: design" tag and points users at the
new how-to (docs/security/cross-ledger-policy.md) for setup
examples. The design doc's role is "why is it built this way";
the how-to's role is "how do I configure it."
- Resolver and ResolveCtx code blocks now match the real
implementation: concrete `Fluree`, `Arc<ResolvedGraph>`
return, `&mut ResolveCtx<'_>` argument, ArtifactKind-extended
keys for memo / active. The earlier <S, N> speculation came
from CLAUDE.md's workspace pattern; reading the actual Fluree
struct on slice 3 revealed it's concrete.
- ResolvedGraph and GovernanceArtifact code blocks match shipped
code. ResolvedGraph no longer carries a fictional `fingerprint:
ContentId` field; GovernanceArtifact lists only the
`PolicyRules` variant that's actually implemented, with a
pointer to Scope for the reserved variants.
- CycleDetected.chain shows the (ArtifactKind, String, String,
i64) shape that landed in the memo-key fix. The
UnsupportedFeature variant is documented inline.
- "Phasing" table + "Test plan" section + "Out of scope" list
collapse into a single Scope section split as Implemented /
Reserved / Out of scope. Phase numbers stop driving the
organization — readers see what works today, what shares the
resolver contract but doesn't have a materializer yet, and
what is structurally excluded.
- Trust model, drop interaction, same-instance constraint
sections drop "v1" / "Phase 4 introduces..." forward-looking
language. Present-tense descriptions of the actual contract
plus pointers to Scope for what hasn't shipped.
- Caching section drops "For Phase 1a" framing; describes the
Moka cache as the implementation and explains why the
LeafletCache unification is deliberately deferred.
No content was removed that wasn't either drift, plan-language,
or duplicated against the code. All the substantive design
rationale (term-space translation, identity contract, cache
shape, cycle detection logic, error variant taxonomy, drop
behavior) is intact.
Three spots in docs/ledger-config/setting-groups.md described
pre-Phase-1a behavior as current:
- The "Current limitations" paragraph under f:policySource
said cross-ledger isn't supported. It is, on f:policySource.
Replaced with two paragraphs: one naming the cross-ledger
support and pointing at the new how-to, one naming what's
still rejected (f:atT, f:trustPolicy, f:rollbackGuard) and
that the other *Source predicates remain same-ledger only.
- The f:GraphRef table row for f:ledger said it was for
cross-ledger references "not yet supported for constraint
sources" — which was narrowly true but undersold that it IS
supported on f:policySource. Rewritten to enumerate exactly
where it's honored vs rejected, with a how-to link.
- The bottom note under "f:GraphRef: referencing source graphs"
said cross-ledger "is defined in the schema but not yet
supported for constraint source resolution" — same drift.
Now explicitly distinguishes f:policySource (works) from the
others (parsed but rejected with an explicit error so
operators don't get a silent fallback to local).
All three changes link to the new docs/security/cross-ledger-policy.md
how-to where users get the configuration pattern, the policy_class
filtering contract, and the f:AccessPolicy baseline semantics.
New docs/security/cross-ledger-policy.md walks an operator through
the two-ledger pattern end-to-end: when to use it, what each
ledger holds, the TriG to put on the model ledger, the TriG to
put on the data ledger's #config, how f:policyClass filtering
narrows which rules apply, and how to actually engage the policy
path from an HTTP client.
The doc surfaces the load-bearing operational facts that the
design doc covers as contracts:
- Exact-IRI matching on f:policyClass (no subclass entailment),
and the {f:AccessPolicy} default when no policy class is
configured. Table shows how D's config selects which rules
from M apply.
- The auto-engagement caveat: the JSON-LD query route only
invokes policy enforcement when the request carries a policy
opt (fluree-policy-class header, fluree-identity header, or
inline opts.policy). A configured f:policySource alone isn't
enough at the HTTP layer today; programmatic Rust callers
invoking db_with_policy don't see this gating. Operators
deploying cross-ledger policy need to set the header.
- The Phase 1a limitations table: f:atT / f:trustPolicy /
f:rollbackGuard reject with UnsupportedFeature;
opts.identity + cross-ledger combination is a config error;
reserved graphs (#config / #txn-meta) reject before storage
round-trip; cross-ledger is only wired for f:policySource so
far (other source predicates fail closed on f:ledger).
- The 502 surface and the eight CrossLedgerError variants with
the trigger for each, plus the example JSON body shape that
surfaces in responses.
- Cache + update semantics: resolved_t-keyed cache, single
resolved_t per request, no proactive invalidation on M drop
(next request fails closed).
Cross-linked from docs/SUMMARY.md (security section) and
docs/security/README.md (under the policy subsection, right after
programmatic-policy). The how-to itself links back to the design
doc for the why-it-works-this-way rationale.
Sweep of the related-documentation sections to surface the new
cross-ledger how-to from every adjacent entry point. Without this,
a reader who lands on policy-model.md or policy-in-queries.md (the
natural starting points for "how does Fluree do access control")
would never discover that cross-ledger references exist unless
they happened to read setting-groups.md.
Added a one-line pointer to docs/security/cross-ledger-policy.md
from:
- docs/security/programmatic-policy.md (Related Documentation)
- docs/security/policy-model.md (Related documentation)
- docs/security/policy-in-queries.md (Related documentation)
— notes the query-time engagement angle
- docs/security/policy-in-transactions.md (Related documentation)
— notes the tx-time engagement angle
- docs/concepts/policy-enforcement.md (Related documentation)
- docs/ledger-config/README.md (new Related section linking the
how-to and naming the f:GraphRef shape it builds on)
No content changes to the linked-from docs beyond the pointer
itself — the substantive cross-ledger material lives in the
how-to and the design doc.
…a ledger
A data ledger D's #config can now declare f:constraintsSource with
f:ledger pointing at model ledger M. M's f:enforceUnique
annotations apply to D's transactions: a tx that would create a
duplicate value on a property M has marked unique is rejected,
even though the annotation triple never lives on D.
Implementation follows the policy pattern with one new wrinkle —
the threading depth. enforce_unique_after_staging sits deep in
the stage_txn pipeline and didn't have access to &Fluree before.
The three callers of enforce_unique_after_staging are all methods
on Fluree with &self, so the fix is a two-function threading:
enforce_unique_after_staging and resolve_per_graph_unique_sids
both gain a fluree: &crate::Fluree parameter; the call sites pass
self. No public API changes outside the cross_ledger module.
cross_ledger module additions:
ArtifactKind::Constraints variant (the memo / cycle key
includes it so a memoized PolicyRules entry can't be returned
to a Constraints lookup or vice versa).
GovernanceArtifact::Constraints(ConstraintsArtifactWire)
variant. ConstraintsArtifactWire is structurally simple — just
the model_ledger_id / graph_iri / resolved_t origin plus a
Vec<String> of property IRIs. A translate_to_sids helper
encodes each IRI against D's snapshot and drops unresolvable
ones (D has no data of those properties, so the constraint
can't be violated either way — same semantics as same-ledger
encode_iri returning None).
cross_ledger::WireOrigin shared by Constraints (and any future
variant in this crate). PolicyArtifactWire keeps its own origin
type from fluree-db-policy for now; unification into
fluree-db-core would be a follow-up.
constraints_materializer.rs reads M's constraints graph at
resolved_t via the same POST scan the same-ledger flow uses
(`?prop f:enforceUnique true` with xsd:boolean datatype),
decodes each subject Sid back to its IRI form for the wire.
A failure to decode a property Sid surfaces as
TranslationFailed rather than silent drop — losing a unique
constraint would weaken enforcement.
tx.rs::resolve_per_graph_unique_sids:
Partitions the constraint sources into local vs cross-ledger.
Local sources go through the existing scan path. Cross-ledger
sources dispatch through resolve_graph_ref (sharing a single
ResolveCtx across all cross-ledger sources for the same tx so
resolved_t / memo benefits apply); the resulting wire is
translated against D's snapshot and merged into unique_sids.
CrossLedgerError surfaces as TransactError::Parse with the
full Display preserved in the message (model_ledger_id,
graph_iri, failure variant). The resulting HTTP class at the
API surface is ApiError::Transact (400 Bad Request) rather
than ApiError::CrossLedger (502 Bad Gateway) — that's a
staging-path quirk (TransactError is the only error type the
staging pipeline knows how to return). A future refactor that
threads the cross_ledger result through staging without
type-erasing it would surface the 502 mapping directly; for
now the diagnostic content is preserved in the body.
resolve_constraint_source_g_ids is renamed
resolve_constraint_source_g_ids_for and accepts a slice of
references (the partition path needs to pass borrowed sources).
The old name's cross-ledger f:ledger rejection is preserved as
defense in depth — the partition path filters local sources
into it, so the rejection only fires if some future caller
bypasses the partition.
Two new e2e tests in it_constraints_cross_ledger.rs:
data_ledger_tx_enforces_model_ledger_unique_constraint —
M holds ex:email f:enforceUnique true in a named graph. D
seeds ex:alice with that email, then writes a cross-ledger
constraints config pointing at M, then attempts ex:bob with
the same email. The tx fails with
UniqueConstraintViolation. M's annotation never appears on
D, but D enforces it.
cross_ledger_constraints_missing_model_fails_tx_closed —
D's config points at a model ledger that doesn't exist on
this instance. Tx fails with a diagnostic naming the
missing model ledger. No silent allow.
Pre-existing slice-2 regression tests
(constraints_source_unknown_graph_fails_closed,
constraints_source_cross_ledger_fails_closed) still pass — the
unknown-graph case still routes through the local resolver
(unchanged), and the cross-ledger case now resolves the model
ledger lookup which fails with ModelLedgerMissing, whose Display
contains "cross-ledger" / "f:ledger" matching the existing
message assertion.
The cross-ledger feature now covers two subsystems (policy and
constraints), so docs that named only policy needed updating.
docs/security/cross-ledger-policy.md:
Retitled "Cross-ledger policy and constraints." The intro now
enumerates both subsystems with a note that the others share
the resolver contract but aren't materialized yet. New
"Cross-ledger uniqueness constraints" section walks through
the configuration pattern with TriG examples and calls out
three specifics that differ from policy:
- Constraints are enforced at tx time, not query time, so
no policy-header trick is needed to engage them.
- Cross-ledger constraint failures surface as HTTP 400
(TransactError) rather than 502 (CrossLedger) due to a
staging-pipeline quirk — the diagnostic content is
preserved but the status differs from the policy path.
- The wire artifact has no f:policyClass-style filter;
every property M declares unique applies.
Limitations table updated: f:constraintsSource removed from
the "rejected on" list. The remaining subsystems
(f:shapesSource / f:schemaSource / f:rulesSource) still fail
closed on f:ledger.
docs/design/cross-ledger-model-enforcement.md:
Scope > Implemented moves f:constraintsSource from Reserved
to Implemented with a sentence on how it manifests
(UniqueConstraintViolation on duplicates against M's
enforceUnique set, even though the annotation never lives
on D). Reserved section drops the constraints entry.
docs/ledger-config/setting-groups.md:
Three call-outs updated:
- policyDefaults limitation paragraph names both policySource
and constraintsSource as cross-ledger capable.
- f:GraphRef table row for f:ledger names both subsystems
and updates the still-rejected list.
- Bottom note on cross-ledger GraphRef does the same.
docs/SUMMARY.md and docs/security/README.md:
Link titles updated to "Cross-ledger policy and constraints."
README section text adds a constraints-specific bullet
alongside the policy bullets.
No content was removed; entries that named only policy were
expanded to also name constraints.
…edger
A data ledger D's #config can now declare f:schemaSource with
f:ledger pointing at model ledger M's ontology graph. The
whitelisted schema axioms in M (rdfs:subClassOf,
rdfs:subPropertyOf, rdfs:domain/range, owl:equivalentClass /
equivalentProperty / inverseOf / sameAs / imports, and rdf:type
declarations for owl:Class / ObjectProperty / DatatypeProperty /
SymmetricProperty / TransitiveProperty / FunctionalProperty /
InverseFunctionalProperty / Ontology / rdf:Property) are
projected into a SchemaBundleFlakes against D's snapshot and fed
to D's reasoner exactly like a same-ledger schema bundle.
Phase 1b-a scope: SINGLE GRAPH ONLY. The owl:imports triples
flow through the wire artifact (so a future reader can see what
M declared), but the materializer does NOT recursively follow
imports across ledgers yet. Transitive cross-ledger imports
land in a follow-up where the resolver's cycle detection
(ResolveCtx.active) finally gets exercised for real.
Implementation parallels the constraints work:
Types: ArtifactKind::SchemaClosure variant (memo / cycle key
includes it so a memoized SchemaClosure entry never collides
with PolicyRules / Constraints lookups for the same M graph,
and vice versa). GovernanceArtifact::SchemaClosure(SchemaArtifactWire).
SchemaArtifactWire is { origin, triples: Vec<WireTriple> };
WireTriple is { s, p, o } with all three positions IRIs (the
schema whitelist is Ref-valued in practice, so literal handling
is omitted from Phase 1b-a — a non-Ref-valued whitelist entry
silently skips at projection time).
Materializer (schema_materializer.rs) replays the same per-
predicate PSOT scan and per-class OPST scan that
build_schema_bundle_flakes uses same-ledger, but against M's
snapshot/overlay/t. Each matching flake's s/p/o Sids are
decoded back to IRIs via M.snapshot.decode_sid. A failure to
decode a schema Sid surfaces as TranslationFailed rather than
silent drop — losing a schema axiom would weaken reasoning
observably.
Translator (SchemaArtifactWire::translate_to_schema_bundle_flakes)
encodes each triple's IRIs against D's snapshot.encode_iri and
builds synthetic Ref-valued Flakes with dt = (JSON_LD ns, "id")
— the canonical @id datatype Sid, stable across ledgers because
JSON_LD is a well-known pre-registered ns_code. Unresolvable
IRIs drop their triples silently (D has no data of those IRIs
so the missing axiom can't fire), matching the same-ledger
semantics where encode_iri returns None for unseen IRIs in
build_schema_bundle_flakes.
fluree-db-query gains SchemaBundleFlakes::from_collected_schema_triples
as a public helper exposing the sort + dedupe + 4-index-build
logic. Same-ledger build_schema_bundle_flakes is unchanged; the
helper exists for cross-ledger to construct a SchemaBundleFlakes
with the same shape without duplicating the post-collection
plumbing.
view/query.rs::attach_schema_bundle dispatches on
schema_source.ledger.is_some(): cross-ledger goes through
resolve_graph_ref + translate_to_schema_bundle_flakes; same-
ledger goes through the unchanged resolve_schema_bundle +
get_or_build_schema_bundle_flakes path. Both produce a
SchemaBundleFlakes that lands on executable.reasoning.schema_bundle.
Two resolver-level integration tests in it_cross_ledger_resolver.rs:
cross_ledger_schema_materializes_whitelisted_axioms —
M holds ex:Dog rdfs:subClassOf ex:Animal, ex:friend
rdfs:subPropertyOf ex:knows, plus rdf:type owl:Class
declarations. The wire artifact contains all of these in
IRI form with the expected canonical IRIs (the rdfs:
subClassOf full IRI, the owl:Class full IRI, etc.).
cross_ledger_schema_empty_graph_yields_empty_wire —
A graph that exists but holds no whitelisted axioms
(just regular data) yields zero wire triples, not an error.
Reasoner-level enforcement (D's queries see M's class hierarchy)
will be exercised when Phase 1b-b lands the transitive owl:imports
support and the broader integration tests around it; the wire
artifact contract is the load-bearing piece this slice pins.
The cross-ledger feature now covers three subsystems (policy,
constraints, schema), so the docs that previously named only
the first two needed updating.
docs/design/cross-ledger-model-enforcement.md:
Scope > Implemented adds f:schemaSource with a note that
single-graph is the current scope and transitive owl:imports
across ledgers is reserved. Reserved section reframes the
schema entry around what's specifically missing (the cross-
ledger import-walk through resolve_graph_ref / ResolveCtx.active,
and f:ontologyImportMap cross-ledger entries) rather than
schema as a whole.
docs/security/cross-ledger-policy.md:
Retitled "Cross-ledger policy, constraints, and schema."
Intro lists three subsystems with the schema entry noting the
single-graph limitation. New "Cross-ledger schema / ontology"
section walks through the configuration pattern with a TriG
example (M holds ex:Dog rdfs:subClassOf ex:Animal in a named
graph; D's config points f:schemaSource at it; queries with
reasoning enabled see ex:Dog as ex:Animal even though the
subClassOf triple never lives on D). The section calls out
three specifics that differ from policy/constraints:
- Reasoning must be enabled (f:reasoningModes or query
reasoning opt-in) for the schema axioms to actually fire.
- Schema failures surface as ApiError::OntologyImport rather
than ApiError::CrossLedger, preserving continuity with the
same-ledger ontology-imports error path.
- Single graph only; M's owl:imports triples flow through
the wire but cross-ledger import recursion is reserved.
Limitations table updated: f:schemaSource removed from the
"rejected on" list; a new entry names "transitive owl:imports
across model ledgers" as not yet honored.
docs/ledger-config/setting-groups.md:
Three call-outs updated (policyDefaults limitations paragraph,
f:GraphRef table row for f:ledger, bottom note on cross-ledger
GraphRef) — schema added with the single-graph caveat.
docs/SUMMARY.md and docs/security/README.md:
Link title updated to "Cross-ledger policy, constraints, and
schema." README section text adds a schema bullet alongside
policy and constraints.
Foundation for cross-ledger SHACL: the SHACL whitelist includes
literal-valued predicates (sh:minCount, sh:maxCount, sh:pattern,
sh:minLength, sh:maxLength, sh:message, sh:severity, sh:hasValue
when literal-typed) that can't survive Ref-only round-tripping.
The wire form gains a tagged-union object position so shapes can
carry literals through the model→data translation alongside the
Refs that schema already uses.
WireTriple.o changes from `String` (IRI) to `WireObject`, a small
enum:
WireObject::Ref(String)
IRI reference. Encoded via snapshot.encode_iri to a Sid at
translation time. Schema's existing whitelist projects only
Refs; the migration is mechanical (wrap each pushed IRI in
WireObject::Ref).
WireObject::Literal { value, datatype, lang }
Canonical lexical value paired with the datatype IRI (e.g.,
"http://www.w3.org/2001/XMLSchema#integer") and an optional
language tag. The translator at D's side branches on the
datatype IRI to reconstruct the original FlakeValue:
xsd:integer → Long, xsd:string → String, xsd:boolean →
Boolean, etc. Unknown datatypes fall back to FlakeValue::String
so a constraint that references a custom datatype simply
doesn't match on type-sensitive predicates — same observable
behavior as if the constraint weren't authored.
WireObject::iri() helper is a convenience for Ref-only callers
(currently schema).
Schema's translator changes mechanically:
- schema_materializer pushes WireObject::Ref(o_iri) instead of
a bare String.
- SchemaArtifactWire::translate_to_schema_bundle_flakes pattern-
matches on WireObject::Ref and silently skips any literal-
valued whitelist triple. Schema's whitelist is Ref-valued in
practice (rdfs:subClassOf, owl:equivalentClass, owl:imports,
rdf:type for owl:Class/etc.), so the skip is a no-op for
well-formed input and a defensive fail-safe for malformed.
Existing schema integration tests assert object equality via the
new shape:
matches!(&t.o, WireObject::Ref(o) if o == "...")
rather than t.o == "...".
No behavior change for schema; cross-ledger schema tests pass
byte-identical. This commit is purely the wire-form extension
that makes the SHACL materializer (next slice) tractable.
…ed namespace
The architecture matches the reasoning/schema pattern: detect at
the API boundary, resolve the cross-ledger artifact to IRI-form
upfront, thread the wire through staging as an internal
governance input, compile against the *staged* namespace state
at validation time. No M-side Sids leak into D; no namespace
churn (M-only IRIs simply drop their triples, matching the
"shape can't apply to data D doesn't have" semantics).
The key correction over the earlier reverted attempt:
translation MUST run against the staged NamespaceRegistry, not
D's pre-staging snapshot. The pre-stage snapshot doesn't know
about IRIs the in-flight transaction is introducing — e.g.,
the very `ex:Person` instance being validated — so encoding
returns None for all of M's shape targets and the shape
silently compiles to nothing. Compiling against the staged
registry, which has D's snapshot namespaces plus the tx's
freshly-allocated ones, makes both M's M-side IRIs (when they
match D's data namespaces) AND the tx's new IRIs resolvable.
fluree_db_transact::NamespaceRegistry gains
lookup_sid_for_iri: pure-lookup Sid resolution that returns
None on unknown prefixes (vs sid_for_iri which allocates).
This is the right primitive for the cross-ledger translation
path — allocating a fresh code for every M-only term would
churn D's namespace map for no benefit, since shapes
referencing IRIs D has never seen can never apply to data D
doesn't have.
cross_ledger module additions:
ArtifactKind::Shapes variant + GovernanceArtifact::Shapes
(ShapesArtifactWire) variant. ArtifactKind keyed into memo
and cycle stack so a Shapes lookup never collides with
PolicyRules / Constraints / SchemaClosure for the same
(M, graph, t).
ShapesArtifactWire { origin, triples: Vec<WireTriple> }.
Triples carry the SHACL whitelist (sh:targetClass /
sh:property / sh:minCount / sh:pattern / sh:message / ...)
plus rdf:first / rdf:rest internals for sh:in / sh:and /
sh:or / sh:xone list expansion. Object positions handle both
Ref and Literal via WireObject; literal datatype dispatch
reconstructs FlakeValue (xsd:integer → Long, xsd:boolean →
Boolean, xsd:string → String, etc.).
shapes_materializer.rs mirrors the schema materializer's
per-predicate PSOT scan against M, decoding each flake's
s/p/o Sids back to IRIs. Literal-valued objects encode the
datatype IRI via M.snapshot.decode_sid alongside the lexical
value.
ShapesArtifactWire::translate_to_schema_bundle_flakes(&NamespaceRegistry)
is the load-bearing translator. It encodes each wire IRI via
NamespaceRegistry::lookup_sid_for_iri (pure lookup, no
allocation). Unresolvable IRIs drop their triples. The
resulting SchemaBundleFlakes is shaped to drop into the
existing SchemaBundleOverlay → ShapeCompiler → ShaclEngine
flow as a synthetic g_id=0 overlay.
tx.rs additions:
resolve_cross_ledger_shapes_for_tx detects D's resolved
config at transaction entry, resolves the wire artifact via
resolve_graph_ref (sharing the per-tx ResolveCtx so memo +
governance cache benefits apply), and returns the resolved
artifact for threading. Returns None when no cross-ledger
source is configured, falling through to the unchanged
same-ledger flow.
StagedShaclContext gains two fields: cross_ledger_shapes
(the resolved wire) and staged_ns (the staged
NamespaceRegistry). Both Optional; both populated only when
the cross-ledger path is engaged.
apply_shacl_policy_to_staged_view detects the cross-ledger
inputs in ctx and constructs a synthetic GraphDbRef that
wraps a SchemaBundleOverlay over D's base novelty. The
overlay surfaces the wire-translated shape flakes at g_id=0,
and ShapeCompiler scans them via the standard
ShaclEngine::from_dbs_with_overlay path. No changes to
fluree_db_shacl.
stage_with_config_shacl calls
resolve_cross_ledger_shapes_for_tx before stage_txn,
populates StagedShaclContext after staging completes (when
the staged ns_registry is in hand), and routes the rest of
the SHACL flow unchanged.
Same-ledger SHACL: completely untouched. Cross-ledger config
absent → resolve_cross_ledger_shapes_for_tx returns None →
ctx.cross_ledger_shapes is None → apply_shacl_policy takes
the existing same-ledger branch.
Two e2e tests in it_shapes_cross_ledger.rs:
data_ledger_tx_rejected_by_cross_ledger_shape — M holds an
sh:targetClass ex:Person shape requiring sh:minCount 1
ex:name. D references M's shapes graph in #config. Inserting
ex:alice as ex:Person without ex:name is rejected with
ShaclViolation. This test failed in the prior architecture
(translation against base.snapshot returned None for ex:Person
→ shape never compiled → tx silently passed); it passes here
because the staged ns_registry has ex: registered from the
in-flight insert.
data_ledger_tx_passes_when_cross_ledger_shape_satisfied —
same setup, ex:bob with ex:name "Bob" — accepted. Pinned
to prove the dispatch doesn't reject everything indiscriminately
(and that the previous test failed for the right reason, not
because nothing was being validated).
Regression: same-ledger SHACL paths (12 it_config_graph SHACL
tests, others) all pass unchanged.
Commit_transfer.rs and Turtle insert paths pass None for the
cross-ledger context fields — commit replay re-validates
same-ledger only (the leader already validated cross-ledger
against M when authoring), and Turtle insert can be extended
to call resolve_cross_ledger_shapes_for_tx when a use case
emerges. Both noted inline.
…ger files CI parity — re-run `cargo fmt --all` and `cargo clippy --fix` on the cross-ledger materializers and their integration tests. Pure cosmetic: multi-line return types broken across lines, vec literals one-per-line, needless `r#"..."#` raw strings collapsed to `r"..."` where no `"` appears in the body. No semantic change.
`TxnOpts.shapes: Option<JsonValue>` carries a JSON-LD document of SHACL shapes (sh:NodeShape / sh:targetClass / sh:property / ...) that enforce only for the current transaction and do not persist into the ledger. Pipeline: - `stage_with_config_shacl` captures `txn.opts.shapes` before `stage_txn` consumes the txn. - After staging, `inline_shapes::parse_inline_shapes_to_bundle` parses the JSON-LD through `fluree_graph_json_ld::expand` → `adapter::to_graph_events` into a `FlakeSink` that writes against the staged `NamespaceRegistry` (D's snapshot ns + tx-introduced IRIs). The resulting flakes feed `SchemaBundleFlakes`. - `StagedShaclContext.inline_shape_bundle` is wrapped in a separate `SchemaBundleOverlay` and pushed to `shape_dbs` as an additional `GraphDbRef`. `ShaclEngine::from_dbs_with_overlay` treats multiple shape DBs as a union, so inline shapes enforce additively alongside same-ledger `f:shapesSource` and cross-ledger wire artifacts. Allowing fresh ns_code allocation here (unlike cross-ledger which is lookup-only) is acceptable: the caller explicitly opted into these IRIs for this transaction, which is functionally equivalent to staging the shapes directly. The flakes never persist. HTTP path: `routes::transact::execute_transaction` pulls `body["opts"]["shapes"]` into `TxnOpts` so REST clients can use the field via the standard tx body. Other write surfaces (Turtle insert, commit replay) thread `inline_shape_bundle: None` — inline SHACL is a JSON-LD authoring-time construct; commit replay carries no opts payload. Tests (`it_shapes_inline.rs`): - inline shape rejects violating tx - inline shape accepts valid tx - inline shapes do not persist after tx (next tx without opts.shapes is not constrained) - inline shape layered on cross-ledger shape enforces both
Resolves the long-standing gap where `f:rulesSource` was parsed by the config layer but ignored at query time. Same-ledger references now route the datalog rule extractor at the configured graph; cross-ledger references (`f:ledger` set) are skipped here and deferred to the cross-ledger resolver in a follow-up commit. Wiring: - `EffectiveDatalogConfig` gains `rules_source` so consumers see the resolved `GraphSourceRef`. - `Fluree::resolve_and_attach_config` pre-resolves the local graph selector to a `GraphId` once per view load (identity-independent, so it lives outside `apply_config_datalog`'s override path). - `GraphDb` carries the pre-resolved id via `rules_source_g_id`. - `view/query.rs::prepare_query` copies it into `ExecutableQuery.reasoning.rules_source_g_id`. - `ReasoningConfig` and `compute_derived_facts` plumb the override through to `execute_datalog_rules_with_query_rules`, which builds a separate `GraphDbRef` at the configured graph for the `f:rule`-predicate PSOT scan. The fixpoint loop continues to execute against the query graph. - Unsupported `GraphSourceRef` dimensions (atT, trustPolicy, rollbackGuard) silently fall through to "no rules source" — the cross-ledger materializer (next commit) will enforce them explicitly. Tests (`it_rules_source.rs`): - rules in a named graph are honored when `f:rulesSource` points at that graph. - same rule, same named graph, no `f:rulesSource` config → not fired (negative control proving the wiring is load-bearing).
…ledger
Closes the last unsupported row in the cross-ledger governance
matrix. With this commit, every `f:GraphRef`-shaped predicate
(`f:policySource`, `f:constraintsSource`, `f:schemaSource`,
`f:shapesSource`, `f:rulesSource`) routes through the same
resolver contract and can carry `f:ledger` to reference a model
ledger.
Wiring:
- `ArtifactKind::Rules` and `GovernanceArtifact::Rules` round out
the four existing variants; the memo / cycle-detection key
already keys on `ArtifactKind` so no collision with other
artifacts at the same `(ledger, graph, t)`.
- `RulesArtifactWire { origin, rules: Vec<String> }` carries the
raw JSON-LD rule bodies. No term translation is needed —
`parse_query_time_rule` resolves IRIs against D's snapshot the
same way `opts.rules` are handled.
- `rules_materializer::materialize_rules` opens M at the
resolved t, scans the configured graph for `f:rule`
predicates, and emits one `String` per `FlakeValue::Json`
literal (mirroring the same-ledger extractor's `if let
FlakeValue::Json` guard for non-JSON values).
- `resolver::materialize_artifact` adds the `Rules` arm,
dispatching through the same per-tx memo + GovernanceCache.
- `view/query.rs::attach_cross_ledger_rules` runs at query
preparation: if datalog is enabled on the executable and the
resolved config carries a `f:rulesSource` with `f:ledger`,
dispatch the resolver and merge `wire.parsed_rules()` into
`executable.reasoning.modes.rules`. Same-ledger references
fall through to the existing `rules_source_g_id` path
(previous commit).
Docs:
- `docs/security/cross-ledger-policy.md`: removed the "shapes /
rules not yet implemented" cell from the matrix; updated the
intro paragraph.
- `docs/ledger-config/setting-groups.md`: updated the `f:ledger`
column and the closing paragraph to reflect full coverage.
Tests (`it_rules_cross_ledger.rs`):
- D's query pulls M's grandparent rule via `f:rulesSource`
+ `f:ledger` and derives `ex:charlie` as alice's grandparent.
- Missing model ledger surfaces as `ApiError::CrossLedger`
(mapped to HTTP 502 server-side), not silently dropped.
…-loud rulesSource Four findings from review, all addressed: **High — cross-ledger `f:defaultGraph` selectors were broken.** Every materializer's `graph_id_for_iri(graph_iri)` lookup returned `None` for `https://ns.flur.ee/db#defaultGraph` (a sentinel IRI, not a registry entry), so any cross-ledger ref naming the model ledger's default graph failed with `GraphMissingAtT`. Centralised the resolution in a new `cross_ledger::resolve_selector_g_id` helper that maps `f:defaultGraph` to `g_id=0` and otherwise delegates to the snapshot's graph registry. All five materializers (policy, constraints, schema, shapes, rules) call it. Coverage: `cross_ledger_rules_with_default_graph_selector`. **High — `ResolveCtx` was not request-scoped across artifacts.** `attach_cross_ledger_rules` and `attach_schema_bundle` each built a fresh context, so the lazy per-request `resolved_ts` capture ran twice and could observe different heads on the same model ledger if M advanced between awaits. `build_executable_for_view` now constructs a single `ResolveCtx` at the start of preparation and threads it through both attach helpers; the per-request memo and head-t cache are now actually shared as the contract requires. **Medium — same-ledger `f:rulesSource` misconfig silently fell back to legacy rule scanning.** Unknown `graph_selector` IRIs and the reserved dimensions (`f:atT`, `f:trustPolicy`, `f:rollbackGuard`) all collapsed to `None`, identical to "no rulesSource configured". The new helper `resolve_local_rules_source_g_id` mirrors the SHACL same-ledger resolver shape: returns `Err(ApiError::config(...))` with an explicit message for each unsupported case and for unknown selector IRIs. `db()` now fails loudly when the operator's governance config is wrong. Coverage: `unknown_rules_source_graph_iri_fails_loudly`, `rules_source_with_unsupported_at_t_fails_loudly`. **Medium — malformed cross-ledger rules were silently skipped.** `RulesArtifactWire::parsed_rules` warned-and-dropped on a JSON parse failure. For admin-authored governance pulled from a model ledger, that silently weakens the configured reasoning model — the worst possible failure mode. Now returns `Err(CrossLedgerError::TranslationFailed)` on the first bad entry; the existing 502 mapping surfaces it to HTTP clients. Normal write paths reject malformed JSON literals at the storage boundary, so this remains defense-in-depth at the wire boundary — a unit test (`parsed_rules_fails_closed_on_malformed_json`) pins the invariant. No changes to the same-ledger or cross-ledger happy paths; all four pre-existing integration tests (`it_rules_source`, `it_rules_cross_ledger`) still pass alongside the four new ones.
…ect f:txnMetaGraph
Three review findings addressed in one pass:
**High — `ResolveCtx` not shared across all governance entries.**
Previously `wrap_policy` (policy), `query.build_executable_for_view`
(rules + schema), and the tx-side SHACL / constraints paths each
built their own context. Within a single logical request, two
contexts referencing the same model ledger could each lazy-capture
a different head-t if M advanced between awaits — breaking the
per-request consistency contract.
- `ResolveCtx::with_resolved_ts` constructor (new) accepts a
pre-seeded `resolved_t` map so a new ctx can inherit prior
captures.
- `GraphDb` carries `cross_ledger_resolved_ts:
Arc<HashMap<String, i64>>`. `wrap_policy` writes the merged
map back onto the returned view after policy resolution; the
subsequent `query.build_executable_for_view` seeds its own
ctx from this stored map. Policy and reasoning on the same M
now agree on which M version is in effect even though they
enter through separate Rust API calls.
- tx path: each of the three `transact*` entry points
constructs one `ResolveCtx` and threads `&mut` into both
`stage_with_config_shacl` (SHACL) and
`enforce_unique_after_staging` (constraints). The contexts
these helpers used to construct internally are gone.
The data ledger id is captured into an owned `String` at each
tx site so the borrow doesn't conflict with the eventual
`ledger` move into `stage_with_config_shacl`.
**Medium — `f:txnMetaGraph` selector wasn't rejected as reserved.**
The new `resolve_selector_g_id` helper handled `f:defaultGraph`
but fell through to the registry for `f:txnMetaGraph`, so a
cross-ledger ref naming M's txn-meta graph would either succeed
or fail as `GraphMissingAtT` after touching M. Returns
`Err(CrossLedgerError::ReservedGraphSelected)` immediately now,
matching the canonical-ledger reserved-graph guard. Signature
changed from `Option` to `Result<Option, CrossLedgerError>`; all
five materializers updated. Coverage:
`cross_ledger_f_txn_meta_graph_selector_rejected`.
**Medium — formatting failed CI parity.** `cargo fmt --all`
applied; no semantic change.
`TxnOpts.unique_properties: Option<Vec<String>>` carries a list of full property IRIs that the uniqueness enforcer treats as `f:enforceUnique true` for the duration of this transaction. The inline list unions additively with whatever `f:constraintsSource` resolves to (same-ledger or cross-ledger); it never persists into the ledger. Wiring: - `enforce_unique_after_staging` no longer short-circuits when there's no `f:transactDefaults`. It starts the per-graph SID map from the config-resolved set (or empty), then layers the inline IRIs onto every graph the staged flakes touched, then invokes `enforce_unique_constraints` only when the merged map is non-empty. - IRIs the data ledger's namespace map has never seen are dropped silently (no instances → no violation possible), matching the same-ledger contract where `encode_iri` returning `None` drops the would-be constraint. - Affected-graph derivation lifted into a small `affected_graph_ids` helper shared by both the config-resolved path and the inline path so they enforce against the same set of graphs. - Three transact entry points capture `txn.opts.unique_properties` before staging consumes the txn and forward the slice into `enforce_unique_after_staging`. HTTP: `routes/transact::execute_transaction` reads `body["opts"]["uniqueProperties"]` (array of strings) into `TxnOpts.unique_properties` so REST clients can use the field through the standard tx body. Docs: new "Inline opts.uniqueProperties per transaction" section in `docs/ledger-config/unique-constraints.md` covering the additive semantics, the silent-drop policy on unknown IRIs, the no-audit-trail trade-off, and the intended use cases (per-tenant constraints, one-off bulk loads, candidate-constraint testing). Tests (`it_constraints_inline.rs`): - inline property rejects a duplicate value - inline list does not persist (next tx without opts accepts a duplicate) - distinct values satisfy the inline constraint - IRI absent from D's ns map is dropped silently rather than failing the tx
…field) `ReasoningModes` gains an `ontology: Option<JsonValue>` field sourced from the top-level `ontology` key on JSON-LD queries. The JSON-LD doc carries RDFS / OWL axioms (rdfs:subClassOf, rdfs:subPropertyOf, owl:inverseOf, owl:equivalentClass / Property, rdf:type owl:Class / owl:TransitiveProperty, …). The reasoner treats those axioms as if they came from the ledger's `f:schemaSource` graph — but only for this one query. Pipeline: - `parse_reasoning` (fluree-db-query) reads the top-level `"ontology"` key alongside the existing `"reasoning"` and `"rules"` keys; `ReasoningModes::from_query_json` stores it. - `inline_ontology::parse_inline_ontology_to_bundle` (fluree-db- api) parses the JSON-LD doc via `fluree_graph_json_ld::expand` → `adapter::to_graph_events` → `FlakeSink`. Encoding runs against a temp `NamespaceRegistry::from_db(snapshot)` so query-scoped axioms reusing existing IRIs share codes with the snapshot, and any new IRIs allocate request-scoped codes that vanish with the response. The on-disk dictionary is untouched. - `attach_schema_bundle` refactored: pulls the configured schemaSource bundle resolution into a private `resolve_configured_schema_bundle` helper, then layers any inline bundle on top via `inline_ontology::merge_bundles` — which feeds the concatenated flakes back through `SchemaBundleFlakes::from_collected_schema_triples` to dedupe, sort, and rebuild the four index orderings. - `SchemaBundleFlakes::flakes_for_merge()` (new) returns the SPOT flakes as a `Vec` suitable for re-feeding the constructor. Parse / event errors return `ApiError::config` — inline ontology is request-scoped admin input, so silent-drop would weaken the reasoning model the caller explicitly requested. Docs: new "Inline ontology per query" section in `docs/query/reasoning.md` covering the additive semantics, the transient nature, the namespace scoping, and the no-audit-trail trade-off. Tests (`it_ontology_inline.rs`): - inline `rdfs:subClassOf` axiom drives RDFS entailment that the bare ledger doesn't produce; the same query without the inline axiom returns no rows (baseline). - inline axiom doesn't persist: same query without `ontology` on the next call returns no rows.
…e_glue test
`cargo clippy --all --all-features --all-targets` failed on the
`fluree-db-query` lib-test build: a `ReasoningModes { ... }`
literal in the `rewrite_glue` unit test was missing the new
`ontology` field introduced in 6f490dc.
Default-feature builds skipped the unit test, so the gap only
showed up under CI-parity flags.
…inks Audit of `./docs/` against the (subsystem × scope) matrix turned up two genuine gaps and several stale references. Gaps filled: - `docs/security/cross-ledger-policy.md` now has dedicated **Cross-ledger SHACL shapes** and **Cross-ledger datalog rules** sections, each with a TriG configuration example (M-side and D-side) and a Specifics list calling out the staged-namespace compilation rule (shapes) and the fail-closed-on-malformed policy (rules). Previously these subsystems were mentioned in a one-paragraph aside in the intro but had no actual how-to. - Title renamed `Cross-ledger policy, constraints, and schema` → `Cross-ledger governance` to reflect the full five-subsystem scope; intro paragraph rewritten to enumerate all five. Cross-links added so every same-/inline-side doc points readers at the cross-ledger version of its predicate: - `docs/guides/cookbook-shacl.md` "Storing shapes in a named graph" → `#cross-ledger-shacl-shapes`. - `docs/ledger-config/unique-constraints.md` new "Cross-ledger constraint source" subsection → `#cross-ledger-uniqueness-constraints`. - `docs/query/datalog-rules.md` "Database-stored rules" → new paragraph linking `#cross-ledger-datalog-rules`. TOC + index updates: - `docs/SUMMARY.md`, `docs/security/README.md`, and `docs/ledger-config/README.md` reflect the renamed page title and expand the per-subsystem bullet list under it. After this commit every cell of the matrix has both a heading and an explanation in `./docs/`: | Cell | Where | |---|---| | Policy / inline | `security/policy-in-queries.md`, `guides/cookbook-policies.md` | | Policy / same-ledger | `ledger-config/setting-groups.md#policy-defaults` | | Policy / cross-ledger | `security/cross-ledger-policy.md#configuring-the-data-ledger` | | Constraints / inline | `ledger-config/unique-constraints.md#inline-optsuniqueproperties-per-transaction` | | Constraints / same-ledger | `ledger-config/unique-constraints.md#explicit-constraint-source` | | Constraints / cross-ledger | `security/cross-ledger-policy.md#cross-ledger-uniqueness-constraints` | | Schema / inline | `query/reasoning.md#inline-ontology-per-query` | | Schema / same-ledger | `ledger-config/setting-groups.md` + `design/ontology-imports.md` | | Schema / cross-ledger | `security/cross-ledger-policy.md#cross-ledger-schema--ontology` | | Shapes / inline | `guides/cookbook-shacl.md#inline-shapes-per-transaction` | | Shapes / same-ledger | `guides/cookbook-shacl.md#storing-shapes-in-a-named-graph` | | Shapes / cross-ledger | `security/cross-ledger-policy.md#cross-ledger-shacl-shapes` | | Rules / inline | `query/datalog-rules.md#1-query-time-rules` | | Rules / same-ledger | `query/datalog-rules.md#2-database-stored-rules` | | Rules / cross-ledger | `security/cross-ledger-policy.md#cross-ledger-datalog-rules` |
Three review findings, all about unnecessary clones of large owned payloads that ride into / through query and tx preparation. **Medium — `ReasoningModes::ontology` JSON survived compilation.** `attach_schema_bundle` parsed the raw JSON-LD doc into a `SchemaBundleFlakes` overlay, but left the original `JsonValue` sitting on `executable.reasoning.modes.ontology`. Downstream `Query::with_patterns` clones the reasoning config when building the executable; large inline ontologies were paying a full deep clone of the JSON for no consumer. `take()` the field after compilation — only the compiled `Arc<SchemaBundleFlakes>` crosses into execution. **Medium — `cross_ledger_restrictions` cloned at policy assembly.** `build_policy_context_from_opts_inner` took an owned `Option<Vec<PolicyRestriction>>` and then `.clone()`d it inside the cross-ledger branch. `PolicyRestriction` carries strings and hash sets, so a model-ledger policy set with many rules paid a per-request copy that nothing consumed. Pattern-match by move (`if let Some(mut merged) = cross_ledger_restrictions`); no later code references the binding. **Medium/Low — `txn.opts.shapes` cloned before staging.** `stage_with_config_shacl` cloned the inline SHACL JSON-LD doc before `stage_txn(ledger, txn, …)` consumed `txn`. Staging itself never reads `opts.shapes` — only `apply_shacl_policy_to_staged_view` does, post-stage. Make `txn` mut and `take()` `opts.shapes` ahead of the consuming call, dropping the second copy. **Low (acknowledged, not implemented).** Cross-ledger `SchemaArtifactWire` re-translates to `SchemaBundleFlakes` on every query that hits the cache. The wire itself is cached (small; IRIs not Sids) but the translation does sort/dedupe/ allocation per request. A per-data-ledger translated-bundle cache is the obvious next step — left as future work; the trade-off was already noted in the design doc (the alternative is threading a heavy translated bundle through the global cache keyed on D's term space, which is more invasive).
…ance
Five review findings, all about silent governance weakening — typos,
unknown datatypes, or scalar HTTP-body values that should have
errored but instead degraded the configured enforcement to a no-op
or a different semantics. Each path now fails closed.
**M1 — `opts.uniqueProperties` silent unknown-IRI drop.**
`snapshot.encode_iri(iri)` (non-strict) folds unknown IRIs into
the EMPTY namespace (Sid code 0), which silently produces a
non-matching Sid — the constraint becomes *effectively unenforced*
with no error and no warning. Switched to `lookup_sid_for_iri`
against the **staged** namespace registry (which sees IRIs the
in-flight tx introduced), and any IRI the staged registry doesn't
know surfaces as `TransactError::Parse` listing every unresolved
entry. `enforce_unique_after_staging` gained a `staged_ns:
&NamespaceRegistry` parameter; the three transact entry points
in `tx.rs` thread `&ns_registry` after `stage_txn` returns.
**M2 — fail-open datatype fallback in SHACL wire translation.**
Both the materializer (M-side, `shapes_materializer::encode_object`)
and the translator (D-side,
`ShapesArtifactWire::translate_to_schema_bundle_flakes`) were
collapsing unknown datatype Sids to `xsd:string`. A `sh:datatype
xsd:integer` literal on M would have been re-typed to `xsd:string`
on D, changing the shape's semantics. Both spots now return
`TranslationFailed` with the offending datatype Sid/IRI in the
detail. The translator's `literal_to_flake_value` no longer
silently swallows parse failures either (e.g., `"abc"^^xsd:integer`)
— it returns an error so the resolver surfaces `TranslationFailed`
rather than handing the SHACL engine a string-typed value.
**M3 — `lang` dropped on the wire.**
`encode_object` (materializer) and the translator's `Literal`
arm both ignored the `lang` field captured in
`WireObject::Literal`. A `sh:languageIn`-style constraint
against language-tagged literals would have seen the literal as
non-lang-tagged, changing the constraint's match set.
`shapes_materializer::push_triple` now pulls
`f.m.as_ref().and_then(|m| m.lang.clone())` and threads it; the
translator constructs the resulting Flake with `dt =
rdf:langString` and `m = FlakeMeta::with_lang(tag)` so the SHACL
engine sees a properly-tagged literal.
**M4 — `flake_value_to_lexical` used Debug for unknown variants.**
`format!("{other:?}")` produced lexical forms like
`DateTime { ... }` that don't round-trip and won't match on D.
Now enumerates every `FlakeValue` variant with its `Display`-based
canonical lexical form. The two variants without a canonical xsd
lexical (`Vector`, `GeoPoint`) return `TranslationFailed` rather
than fall through to a lossy Debug string — `sh:hasValue` on
Fluree-specific datatypes is out of scope for cross-ledger
transport and must be flagged.
**M6 — HTTP boundary type-validation gaps.**
`routes/transact.rs::execute_transaction` was forwarding
`body["opts"]["shapes"]` straight to `TxnOpts.shapes` without
checking the type (null/scalars sailed through to
`fluree_graph_json_ld::expand` and surfaced as fuzzy internal
errors), and using `filter_map` on `uniqueProperties` to silently
drop non-string elements (a typo like `[42, "ex:p"]` would have
been treated as `["ex:p"]`). Now: `shapes` must be an object or
array, and `uniqueProperties` must be an array of strings only
— both reject with explicit HTTP 400 messages on type mismatch.
The PR description's previous "unknown IRIs drop silently"
phrasing for inline constraints described what was, not what
should have been; the new behavior matches the rest of the
governance code's fail-closed posture.
… / select_graph All five cross-ledger materializers were calling `Fluree::db_at_t(canonical_model_ledger_id, t)`. That entry point routes through `parse_graph_ref` (splits on `#`, `urn:fluree:` strip) and then `select_graph`, which can mutate the returned `GraphDb` in two ways: - flip `view.graph_id` to a non-zero value; - when binary indexes are present, **clone the snapshot and rewire `range_provider`** scoped to that graph. The materializers then ignored `m_db.graph_id` and re-resolved their own `g_id` via `resolve_selector_g_id(&m_db.snapshot, graph_iri)`, but the snapshot itself was still the scoped clone — so subsequent `range_with_overlay` reads went through a range provider bound to the wrong graph. An alias-shaped canonical id like `myorg/dataset:main#txn-meta` would have quietly scoped every M-side scan to graph 1. Canonical ledger ids today don't contain `#`, but the bypass relied on that as an unstated invariant; one mis-canonicalized `NsRecord.ledger_id` would have leaked the txn-meta graph through every cross-ledger artifact. Switched all five to `Fluree::load_graph_db_at_t`, the `pub(crate)` lower-level loader that doesn't apply `parse_graph_ref` / `select_graph`. The returned view has `graph_id = 0` and an untouched `range_provider`, leaving the materializer's explicit `resolve_selector_g_id` lookup as the sole graph selector — which is what the rest of the materializer code already assumed. Files: `policy_materializer.rs`, `constraints_materializer.rs`, `schema_materializer.rs`, `shapes_materializer.rs`, `rules_materializer.rs`. Existing integration tests (`it_cross_ledger_resolver.rs`, `it_policy_cross_ledger.rs`, `it_constraints_cross_ledger.rs`, `it_shapes_cross_ledger.rs`, `it_rules_cross_ledger.rs`) still pass — the behavior on bare canonical ids was always correct; this commit just removes the bypass that the next mis-canonicalized id would have exposed.
…ay validation **Medium — `literal_to_flake_value` only covered three datatype families.** The cross-ledger SHACL translator parsed booleans, the integer family (to `i64`), and `xsd:double`/`xsd:float` only; `xsd:decimal`, the full temporal set (`xsd:dateTime`, `xsd:date`, `xsd:time`, `xsd:gYear`, `xsd:gYearMonth`, `xsd:gMonth`, `xsd:gDay`, `xsd:gMonthDay`), durations (`xsd:duration`, `xsd:dayTimeDuration`, `xsd:yearMonthDuration`), and large integers all fell through to `FlakeValue::String`. A `sh:hasValue "2024-01-15T10:30:00Z"^^xsd:dateTime` on M would have been stored as a `String` on D, while the same shape authored same-ledger would have produced a `FlakeValue::DateTime` — `sh:hasValue` and range constraints would then match different sets of literals between the two sources. Promoted `fluree-db-transact::value_convert` from a private module to a `pub` one and added `parse_xsd_lexical(value, dt_iri) -> Result<Option<FlakeValue>, String>`. Returns: - `Ok(Some(fv))` for any XSD-recognized datatype parsed successfully (same dispatch the same-ledger Turtle/JSON-LD import path uses); - `Err(detail)` for a recognized datatype with an invalid lexical form; - `Ok(None)` for non-recognized datatype IRIs. The cross-ledger translator now delegates to it, so the two code paths produce identical flake shapes for every datatype Fluree recognizes. **Low — comment vs. behavior drift on non-XSD datatypes.** The old comment said "Unknown (non-xsd) datatypes fall through to `String(value)`" but the surrounding posture had moved to fail-closed, leaving the contract ambiguous. The new comment is explicit: non-XSD datatypes fall through *deliberately* because the same-ledger import path does the exact same thing — a custom app-defined datatype IRI (already validated as registered on D by the outer translator) has no `FlakeValue` variant, so the literal is stored as a `String` under that datatype Sid in *both* code paths. Failing closed here would diverge cross-ledger from same-ledger and break the parity contract that lets the SHACL engine match identically. **Medium/Low — HTTP `opts.shapes` array-element validation.** The top-level type check let `[42]` or `[null]` through, even though the error message promised "object or array of objects". Now the array form validates every element: any non-object element fails with `opts.shapes[<idx>] must be a JSON-LD object; got <value>` at HTTP 400.
…a opts.shapes Reviewer M7 read the SHACL setup as "same-ledger and cross-ledger configured shapes might both layer additively"; reading the code that wasn't visible, only the doc wording was. Verified the structural contract: - `LedgerConfig.shapes_source: Option<GraphSourceRef>` is singular. A given `GraphSourceRef` is *either* local (no `f:ledger`) *or* cross-ledger (with `f:ledger`) — never both. The config schema literally cannot represent both at once for shapes. - Same for `policy_source`, `schema_source`, `rules_source` (all `Option<GraphSourceRef>`). - Only `constraints_sources: Vec<GraphSourceRef>` is plural, and that one does additively merge same+cross-ledger entries — but the code (and its doc) already says so explicitly. The code already implements "configured source = XOR; inline opts.shapes layers additively on top of whichever ran." The PR description's matrix is fine (per-cell capability, not co-existence of cells), and `cross-ledger-policy.md` already says "Inline `opts.shapes` layers additively on top of the cross-ledger source" (singular). Two spots had wording that could read ambiguously and are tightened here: - `cookbook-shacl.md` "Inline shapes per transaction" — the "alongside any shapes from `f:shapesSource` (same-ledger or cross-ledger)" line could be parsed as "both at once". Rewritten to spell out that `f:shapesSource` is structurally singular and inline shapes layer on top of whichever variant is configured. - `tx.rs::apply_shacl_policy_to_staged_view` — the inline holder comment said "both can contribute additively when both are configured", ambiguous about which "both" — now explicit that same/cross-ledger are XOR at config and inline is the additive axis. No behavior change.
Three review findings (m1, m2, m9) considered. Two declined, one adopted: **m1 — Cycle-detection integration test (declined).** Already covered by unit tests in `cross_ledger/types.rs::cycle_check_*` (reentry, distinct-ArtifactKind, full chain rendering). The second reviewer's note that "a unit test for check_cycle may be enough until recursive imports/trust policies land" matches the current state; no materializer recurses through the resolver today, so an end-to-end cycle isn't constructible. The defensive `active` stack + `check_cycle` are unit-tested. **m2 — Reserved-graph rejection per-subsystem (declined).** The guard is in the shared `cross_ledger::resolve_selector_g_id` helper; one integration test (`cross_ledger_f_txn_meta_graph_selector_rejected` in `it_rules_cross_ledger.rs`) covers the contract, and the four other subsystems exercise the same helper through every materialize_* call. Per-subsystem duplicates would test the same code path under different `ArtifactKind` tags — no additional confidence. **m9 — Tracing instrumentation (partially adopted).** All five materializers gain a `#[tracing::instrument]` debug-level span named `cross_ledger.<subsystem>.materialize` with `model_ledger`, `graph_iri`, and `resolved_t` fields. The `fluree` parameter is skipped (unprintable). This was the high-value low-cost piece: model-ledger I/O is now visible in Jaeger/OTEL traces alongside the rest of the request, and matches CLAUDE.md's guidance to use `debug_span!` (zero overhead at `RUST_LOG=info` without `--features otel`). The full skill hierarchy / `it_tracing_spans.rs` / `docs/operations/telemetry.md` sweep is deferred to a follow-up — `it_tracing_spans` is non-closed-world (doesn't fail when new spans appear), so this doesn't break anything; documenting the new spans into the skill files is real follow-up work but the second reviewer's "would not block merge solely on this" stance applies. Files: `policy_materializer.rs`, `constraints_materializer.rs`, `schema_materializer.rs`, `shapes_materializer.rs`, `rules_materializer.rs`.
… diagnostics, nits Selected items from the additional review feedback. Each call explained. **#1 — Retry-fragility comment on `take(opts.shapes)`.** No retry exists on the staging path today, but `take()` moves the inline SHACL JSON off the txn, so a future retry would silently skip inline-shapes validation on the second attempt. Hard invariant comment added at the move site; if retry is ever added, defer the take until after `stage_txn` returns successfully. **#3 — `UnsupportedFeature` errors no longer carry an empty `ledger_id`.** `resolve_graph_ref` rejected `f:atT` / `f:trustPolicy` / `f:rollbackGuard` *before* verifying that the ref was actually cross-ledger, so a misrouted same-ledger ref hitting this path would have produced a confusing error with `ledger_id: ""`. Reordered: the same-ledger-ref guard runs first, binding `raw_ledger_ref` once; the unsupported-feature checks now carry the real ledger id in every diagnostic. **#4 — `f:atT` rejection breadth: audited, no gap.** All five `f:GraphRef`-shaped predicates already reject `f:atT` at their same-ledger entry points: - `policy_builder::resolve_policy_source_g_ids` - `tx::resolve_shapes_source_g_ids` (SHACL) - `ontology_imports::resolve_schema_bundle` - `tx::resolve_constraint_source_g_ids_for` - `view::fluree_ext::resolve_local_rules_source_g_id` Plus the cross-ledger `resolver` (now with non-empty ledger_id per #3). Audit-only change; no code modification beyond the doc note. **#2 — Cross-ledger policy target IRI loss is now logged.** `wire_to_restrictions` previously `filter_map`'d unresolvable target IRIs and `for_classes` IRIs silently. A cross-ledger policy could quietly narrow its scope on D if M referenced IRIs D had never registered. Two `tracing::warn!` calls now surface both cases with `restriction_id`, requested count, and resolved count — operators can spot the silent narrowing in logs. Kept the restriction even when targets become empty (rather than dropping the whole restriction): same-ledger via `load_policy_restriction` also produces empty target sets for the same IRI-not-in-snapshot scenario, and dropping the restriction would diverge cross-ledger from same-ledger semantics. The warn log is the parity-preserving signal. `fluree-db-policy` gains a `tracing` dependency for this. **Nits taken:** - `TAtUnavailable` doc-commented as Phase 3 reserved (unreachable today because `UnsupportedFeature { feature: "f:atT" }` fires first). - Empty `uniqueProperties: []` over HTTP is now documented as an intentional "no inline constraints" treatment, not a silent bug. - Cache read/write race in `resolver` documented as benign: losers materialize the same value structurally; single-flight is a future optimization, not a correctness need. - `f:AccessPolicy` IRI promoted from local `const` / duplicated-literal to `fluree_vocab::policy_iris::ACCESS_POLICY`. Two call sites updated; nothing else used the local consts. **Nits skipped per "don't make changes that don't seem needed":** - HTTP-level negative test for malformed `opts.shapes` / `uniqueProperties` (#5) — unit-level coverage exists; HTTP harness setup cost not justified. - Same-ledger `rules_source_g_id` end-to-end test (#6) — already covered by `it_rules_source::rules_source_in_named_graph_is_honored`, which runs an actual query against the named-graph rule. - DynamoDB hard-drop failure handling (#7) — storage layer, unrelated to this PR. - Duplicate pattern match in `shapes_materializer` — cosmetic. - `snapshot.ledger_id` redundancy in inline-shapes / inline- ontology `txn_id` — cosmetic. - `is_last_live_branch` WARN log — unrelated path.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cross-ledger governance + inline opts for every subsystem
Closes the matrix of
(subsystem × scope)for everyf:GraphRef-shaped governance predicate in Fluree: every subsystem now supports same-graph, same-ledger named-graph, cross-ledger, and inline-opts routing.Matrix
f:ledger)opts.policy(+policy_class,policy_values)f:enforceUnique)opts.uniqueProperties(NEW)ontology(NEW)opts.shapes(NEW)rulesWhat this PR adds
Cross-ledger contract (shared)
fluree-db-api/src/cross_ledger/— new module:ResolveCtx,resolver, per-ArtifactKindmaterializers (policy / constraints / schema / shapes / rules),GovernanceCache(per-FlureeMoka), term-neutral wire types, per-request memo + cycle detection.ResolveCtxper logical request — threaded acrosswrap_policy → query(carried onGraphDb.cross_ledger_resolved_ts) and per-tx across SHACL + constraints — so policy, reasoning, and constraints on the same model ledger observe the sameresolved_t.CrossLedgerErrortaxonomy:ModelLedgerMissing,ReservedGraphSelected,GraphMissingAtT,TranslationFailed,UnsupportedFeature(atT / trustPolicy / rollbackGuard),CycleDetected.ApiError::CrossLedger→ HTTP 502 (upstream dependency failure).f:defaultGraphresolves tog_id=0,f:txnMetaGraphrejects asReservedGraphSelectedbefore any I/O on M.f:rulesSource(unknown selector, reserved dimensions) errors loudly atdb()load; malformed cross-ledger rule JSON errors at parse rather than silently weakening governance.Per-subsystem cross-ledger materializers
Each follows the same shape: open M at
resolved_t, project the relevant predicates against the in-flightGovernanceArtifactwire form, translate on D against the appropriate term context (snapshot or staged ns_registry).policy_materializer.rs—PolicyArtifactWirecarries IRI-form restrictions;policy_classfilter (defaulting to{f:AccessPolicy}) intersected at the wire boundary.constraints_materializer.rs—ConstraintsArtifactWirecarries property IRIs; translator → Sid set unioned with config-resolved.schema_materializer.rs—SchemaArtifactWirecarries the schema whitelist (rdfs:subClassOf / subPropertyOf / domain / range, owl:* axioms, rdf:type owl:Class / TransitiveProperty …) projected from M.shapes_materializer.rs—ShapesArtifactWirecarries SHACL whitelist +rdf:first/restforsh:in / and / or / xonelists; must compile against D's staged namespace registry (post-stage, not the pre-stage snapshot) so IRIs the in-flighttx introduced are encodable. Documented in
memory/inline_shapes_path.md.rules_materializer.rs—RulesArtifactWirecarries raw JSON bodies (rules are inherently term-portable JSON-LD); merged intoReasoningModes::rulesat query prep.Same-ledger gaps filled
f:rulesSourcewas parsed by the config layer but ignored at query time. Now:EffectiveDatalogConfig.rules_sourceis resolved atFluree::resolve_and_attach_configintoGraphDb.rules_source_g_id, threaded throughReasoningConfig.rules_source_g_id→compute_derived_facts→extract_datalog_rules(which builds aGraphDbRefat the override g_id for thef:rulescan; the fixpoint still executes against the query graph).enforce_unique_after_stagingfactored to also enforce when config is absent but inlineopts.uniquePropertiesis supplied.Inline opts (new fields)
TxnOpts.shapes: Option<JsonValue>— JSON-LD SHACL shapes parsed viaFlakeSinkagainst staged ns_registry; layered as an additionalSchemaBundleOverlayonshape_dbsalongside same-/cross-ledger sources.fluree-db-api/src/inline_shapes.rs.TxnOpts.unique_properties: Option<Vec<String>>— property IRIs unioned into every affected graph; unknown IRIs drop silently (no instance → no violation).ReasoningModes.ontology: Option<JsonValue>(top-level"ontology"on JSON-LD queries) — RDFS / OWL axioms parsed viaFlakeSinkagainst a per-requestNamespaceRegistryclone; merged with the configured schemaSource bundle viainline_ontology::merge_bundles.fluree-db-api/src/inline_ontology.rs.HTTP wiring
routes/transact.rs::execute_transactionpullsbody["opts"]["shapes"]andbody["opts"]["uniqueProperties"]intoTxnOptsso REST clients use the new fields through the standard transaction body."ontology"and"rules"on JSON-LD queries flow through the existing parser.Notable design decisions (preserved in
docs/design/cross-ledger-model-enforcement.md)f:atTrejected until Phase 3 — lazy per-requestresolved_tcapture is the only producer today, so the head-t observed by the first reference is the version everything in the request enforces.Out of scope (intentional)
owl:importsacross model ledgers. Cross-ledger schema projects axioms from one M graph; imports inside M's schema graph are followed locally but not across ledger boundaries. Acceptable: the ontology can live in a different ledger from the instance data, just not span multiple ledgers via imports.Flureeinstance; cross-instance lookup would need a separate fetch layer.opts.identity— explicitly rejected asApiError::config(ambiguous: M contributes rules, D would need to contribute the identity binding). Useopts.policy_classinstead.Docs
docs/design/cross-ledger-model-enforcement.md— full design (resolver contract, term-space translation, cache shape, failure taxonomy, identity-mode contract).docs/security/cross-ledger-policy.md— user how-to covering all five subsystems.docs/ledger-config/setting-groups.md—f:ledgercolumn reflects full coverage.docs/guides/cookbook-shacl.md— new "Inline shapes per transaction" section.docs/ledger-config/unique-constraints.md— new "Inlineopts.uniquePropertiesper transaction" section.docs/query/reasoning.md— new "Inline ontology per query" section.New integration tests
it_cross_ledger_resolver.rsit_policy_cross_ledger.rs{f:AccessPolicy}, identity-mode rejection.it_constraints_cross_ledger.rsf:enforceUniquetranslated against D.it_constraints_inline.rsopts.uniquePropertiesrejects duplicate, accepts unique, doesn't persist, drops unknown IRI silently.it_shapes_cross_ledger.rsit_shapes_inline.rsopts.shapesrejects/accepts/transient/layered-with-cross-ledger.it_rules_source.rsf:rulesSourcehonored / negative control / fail-loud on misconfig (unknown IRI,f:atT).it_rules_cross_ledger.rsf:defaultGraphselector works,f:txnMetaGraphrejected, missing model ledger →ApiError::CrossLedger, malformed JSON fails closed (unit).it_ontology_inline.rsrdfs:subClassOfdrives RDFS entailment, axioms don't persist.cross_ledger_http_integration.rs(server)