Skip to content

Feature/x ledger policy#1246

Open
bplatz wants to merge 50 commits into
mainfrom
feature/x-ledger-policy
Open

Feature/x ledger policy#1246
bplatz wants to merge 50 commits into
mainfrom
feature/x-ledger-policy

Conversation

@bplatz
Copy link
Copy Markdown
Contributor

@bplatz bplatz commented May 17, 2026

Cross-ledger governance + inline opts for every subsystem

Closes the matrix of (subsystem × scope) for every f:GraphRef-shaped governance predicate in Fluree: every subsystem now supports same-graph, same-ledger named-graph, cross-ledger, and inline-opts routing.

Matrix

Subsystem Inline (opts) Same graph (default) Same ledger (named) Cross ledger (f:ledger)
Policy opts.policy (+ policy_class, policy_values)
Constraints (f:enforceUnique) opts.uniqueProperties (NEW)
Schema / reasoning ✓ top-level ontology (NEW) ✓ (single graph)
Shapes (SHACL) opts.shapes (NEW) (NEW)
Rules (datalog) ✓ top-level rules (NEW) (NEW)

What this PR adds

Cross-ledger contract (shared)

  • fluree-db-api/src/cross_ledger/ — new module: ResolveCtx, resolver, per-ArtifactKind materializers (policy / constraints / schema / shapes / rules), GovernanceCache (per-Fluree Moka), term-neutral wire types, per-request memo + cycle detection.
  • A single ResolveCtx per logical request — threaded across wrap_policy → query (carried on GraphDb.cross_ledger_resolved_ts) and per-tx across SHACL + constraints — so policy, reasoning, and constraints on the same model ledger observe the same resolved_t.
  • CrossLedgerError taxonomy: ModelLedgerMissing, ReservedGraphSelected, GraphMissingAtT, TranslationFailed, UnsupportedFeature (atT / trustPolicy / rollbackGuard), CycleDetected. ApiError::CrossLedger → HTTP 502 (upstream dependency failure).
  • Reserved-graph rejection: f:defaultGraph resolves to g_id=0, f:txnMetaGraph rejects as ReservedGraphSelected before any I/O on M.
  • Fail-closed throughout: misconfigured same-ledger f:rulesSource (unknown selector, reserved dimensions) errors loudly at db() 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-flight GovernanceArtifact wire form, translate on D against the appropriate term context (snapshot or staged ns_registry).

  • policy_materializer.rsPolicyArtifactWire carries IRI-form restrictions; policy_class filter (defaulting to {f:AccessPolicy}) intersected at the wire boundary.
  • constraints_materializer.rsConstraintsArtifactWire carries property IRIs; translator → Sid set unioned with config-resolved.
  • schema_materializer.rsSchemaArtifactWire carries the schema whitelist (rdfs:subClassOf / subPropertyOf / domain / range, owl:* axioms, rdf:type owl:Class / TransitiveProperty …) projected from M.
  • shapes_materializer.rsShapesArtifactWire carries SHACL whitelist + rdf:first/rest for sh:in / and / or / xone lists; must compile against D's staged namespace registry (post-stage, not the pre-stage snapshot) so IRIs the in-flight
    tx introduced are encodable. Documented in memory/inline_shapes_path.md.
  • rules_materializer.rsRulesArtifactWire carries raw JSON bodies (rules are inherently term-portable JSON-LD); merged into ReasoningModes::rules at query prep.

Same-ledger gaps filled

  • f:rulesSource was parsed by the config layer but ignored at query time. Now: EffectiveDatalogConfig.rules_source is resolved at Fluree::resolve_and_attach_config into GraphDb.rules_source_g_id, threaded through ReasoningConfig.rules_source_g_idcompute_derived_factsextract_datalog_rules (which builds a GraphDbRef at the override g_id for the f:rule scan; the fixpoint still executes against the query graph).
  • enforce_unique_after_staging factored to also enforce when config is absent but inline opts.uniqueProperties is supplied.

Inline opts (new fields)

  • TxnOpts.shapes: Option<JsonValue> — JSON-LD SHACL shapes parsed via FlakeSink against staged ns_registry; layered as an additional SchemaBundleOverlay on shape_dbs alongside 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 via FlakeSink against a per-request NamespaceRegistry clone; merged with the configured schemaSource bundle via inline_ontology::merge_bundles. fluree-db-api/src/inline_ontology.rs.

HTTP wiring

  • routes/transact.rs::execute_transaction pulls body["opts"]["shapes"] and body["opts"]["uniqueProperties"] into TxnOpts so REST clients use the new fields through the standard transaction body.
  • Top-level "ontology" and "rules" on JSON-LD queries flow through the existing parser.

Notable design decisions (preserved in docs/design/cross-ledger-model-enforcement.md)

  • Wire artifacts are term-neutral — IRIs not Sids, so M-Sids never leak into D's term space.
  • Resolve at the API boundary; inject artifact; keep downstream source-agnostic — SHACL / constraints / policy code paths don't know whether a source was local or cross-ledger.
  • f:atT rejected until Phase 3 — lazy per-request resolved_t capture is the only producer today, so the head-t observed by the first reference is the version everything in the request enforces.
  • HTTP 502 for cross-ledger — upstream dependency, not 400 (caller error) or 500 (internal).

Out of scope (intentional)

  • Transitive owl:imports across 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.
  • Cross-instance Fluree references — the resolver requires the model and data ledgers be hosted by the same Fluree instance; cross-instance lookup would need a separate fetch layer.
  • Inline policy bundles at query time on cross-ledger configs with opts.identity — explicitly rejected as ApiError::config (ambiguous: M contributes rules, D would need to contribute the identity binding). Use opts.policy_class instead.

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.mdf:ledger column reflects full coverage.
  • docs/guides/cookbook-shacl.md — new "Inline shapes per transaction" section.
  • docs/ledger-config/unique-constraints.md — new "Inline opts.uniqueProperties per transaction" section.
  • docs/query/reasoning.md — new "Inline ontology per query" section.

New integration tests

File Tests
it_cross_ledger_resolver.rs 11 — distinct-ns-codes canary, single-resolution-t, memo & cycle keys, error variants.
it_policy_cross_ledger.rs 6 — model-ledger deny policy enforced on D, policy_class filtering (both sides), default {f:AccessPolicy}, identity-mode rejection.
it_constraints_cross_ledger.rs 2 — cross-ledger f:enforceUnique translated against D.
it_constraints_inline.rs 4 — inline opts.uniqueProperties rejects duplicate, accepts unique, doesn't persist, drops unknown IRI silently.
it_shapes_cross_ledger.rs 2 — staged-namespace compilation of cross-ledger SHACL.
it_shapes_inline.rs 4 — inline opts.shapes rejects/accepts/transient/layered-with-cross-ledger.
it_rules_source.rs 4 — same-ledger f:rulesSource honored / negative control / fail-loud on misconfig (unknown IRI, f:atT).
it_rules_cross_ledger.rs 5 — cross-ledger rule derives result on D, f:defaultGraph selector works, f:txnMetaGraph rejected, missing model ledger → ApiError::CrossLedger, malformed JSON fails closed (unit).
it_ontology_inline.rs 2 — inline rdfs:subClassOf drives RDFS entailment, axioms don't persist.
cross_ledger_http_integration.rs (server) 1 — HTTP 502 for cross-ledger errors.

bplatz added 30 commits May 16, 2026 10:16
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.
bplatz added 11 commits May 16, 2026 20:42
…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.
@bplatz bplatz requested review from aaj3f and zonotope May 17, 2026 14:41
bplatz added 9 commits May 17, 2026 10:46
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant