Skip to content

feat(cli): fluree model — governance tooling for access, entities, and class hierarchy#1516

Merged
bplatz merged 13 commits into
mainfrom
feat/cli-model-access
Jul 18, 2026
Merged

feat(cli): fluree model — governance tooling for access, entities, and class hierarchy#1516
bplatz merged 13 commits into
mainfrom
feat/cli-model-access

Conversation

@bplatz

@bplatz bplatz commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What

Adds a fluree model command family: governance tooling that compiles declared intent into ordinary JSON-LD transactions against the target ledger. No new server API — the commands work identically against local ledgers, fluree-db-server, and hosted stacks. Three facets:

  • model access enable|show — a stateless policy scaffolder. Profiles read / write / intake compile into a policy class (the assignment unit grants and tokens carry) plus one or two thin policies built on the engine's write verbs (f:create/f:update/f:delete). Options: --class (the governed class → f:onClass), --property (column-narrowed writes via f:onProperty), --connected <path> (relationship-gated read; the SPARQL property path is stored verbatim as an @path context term inside f:query), --policy-class (override the assignment-unit IRI), --space + --remote (attach the policy class to a hosted stack's space grant, merging never clobbering), --dry-run.
  • model entity define|show — entity definitions as SHACL node shapes, with a property spec mini-language ("<iri> string required in[a,b]") and --closed (with the rdf:type carve-out). Defining a shape activates transaction-time validation for its class.
  • model class define|showrdfs:Class / rdfs:subClassOf hierarchy; with entailment enabled the hierarchy flows through both query and policy, which is why it lives under governance tooling.

Design notes

  • Stateless by design: there is no stored intent language and nothing to sync. The compiled artifacts are small and self-describing; hand-editing a policy is legitimate authorship, not drift.
  • Atomic replace for owned nodes: re-running enable installs via ONE transaction that wildcard-deletes the two owned policy ids ({policy-class}/view, {policy-class}/write) and inserts the fresh compilation. Merge-upsert is unsafe here: the policy loader gives f:allow precedence over f:query, so a stale f:allow: true would silently disable a newly added --connected gate. Owning both ids on every run also makes profile switches exact (write → read revokes the write policy). entity define gets the same treatment for the shape + property-shape children (a dropped sh:closed/sh:minCount doesn't survive a re-run); the entity class node itself stays additive since it's shared authorship with model class define.
  • Naming: the access command's target flag is --class (it compiles to f:onClass, and model class define --class takes the same IRI for the same concept); --policy-class names the assignment unit by its role.
  • Verb exactness: class targeting matches pre ∪ post state and rdf:type writes match the class they mint, so a Lead grant can never create a Contract — no property allow-list needed. The property surface of a class belongs to its SHACL shape.

Tests

  • Unit tests for all three compilers plus the replace-transaction builder (21 in fluree-db-cli).
  • New engine integration tests pinning the contracts the scaffolder depends on:
    • scaffolder_write_profile_grants_class_ownership_only — the exact emitted shape, stored and selected via policy-class, grants class ownership and nothing else;
    • policy_fquery_with_path_context_term — an @path context term inside a stored f:query survives verbatim and enforces the path;
    • policy_replace_swaps_allow_for_path_gate — the exact replace transaction the CLI emits actually narrows a previously allow-all read profile to a relationship gate (the failure mode the replace semantics exist to prevent).

Docs

docs/cli/model.md (full command reference incl. re-run semantics and the three class-shaped roles), indexed in docs/SUMMARY.md and the CLI command table; picked up automatically by the embedded fluree docs corpus.

bplatz added 12 commits July 8, 2026 11:03
First facet of the governance-model tooling (fluree model). Users
declare intent; the CLI compiles it to stored policies and transacts
them as ordinary data — no bespoke server API, so it works identically
against local ledgers, fluree-db-server, and hosted stacks.

fluree model access enable <dataset> --profile read|write|intake
  --entity <class-iri> [--property <iri>...] [--allow-shared]
  [--class-iri <iri>] [--dry-run] [--remote <r>]
fluree model access show <dataset>

Compilation: a policy class (the assignment unit grants/tokens carry),
a view policy (f:onClass — exact for reads), a property-whitelist
modify policy (f:onProperty + f:allow — class-targeted modify cannot
cover new-subject inserts and f:query evaluates pre-state), and a
declarative fm:AccessProfile node recording the intent so enable is
idempotent (upsert) and future sync/verify re-derive from it.

Property derivation: explicit → SHACL shape → observed data →
fail-closed. Uniqueness partition: properties used by other classes
are excluded by default with the blast radius disclosed
('also used by: Invoice'), included only with --allow-shared; every
run prints an exactness verdict (class-exact vs property-approximate)
and flags the rdf:type creation caveat.

Unit tests cover compilation shapes per profile, the parse gates, and
the IRI validation; verified end-to-end in local mode (enable → show →
identity+policy-class insert allowed → non-whitelisted write denied →
shared-property partition + disclosure).
…tacks

Completes the enable flow end-to-end: after the policies land in the
ledger (data plane), --space merges the compiled policy class into the
space's grant on the dataset via the stack's grants API — the one
system-plane touch in the compiler, since grants are router-owned
invariants (scope validation, membership checks).

- Reads existing grants first and merges classes (never clobbers);
  keeps the existing access level, upgrading read → write with a
  printed notice only when the profile requires writes.
- Policies-then-grant ordering is deliberate: a partial failure leaves
  unused policies (harmless), never a grant naming absent classes.
- Root URL derived from the remote's data-plane URL (…/v1/fluree);
  clear errors when the remote isn't a hosted stack or has no token.
- --space without --remote is a usage error.

Verified against a live stack: enable … --space wrote 4 policies +
attached the grant in one command; a minted space app token then
carried leads2 with the compiled class, a whitelisted insert passed,
and a non-whitelisted write was rejected at the ledger.
Terminology only: user-facing output now prints 'Allowed: N properties',
identifiers use allowed/allow_list, docs say property allow-list. No
behavior change.
Second facet of the governance-model tooling. The entity shape is the
model's single source of truth: the property list is written ONCE here
and everything else derives — model access enable now prefers
shape-derivation for its allow-lists, transaction-time validation
enforces the shape (the shapes-exist heuristic activates reject mode),
and SDK types / form fields can be generated from it later.

fluree model entity define <dataset> --entity <iri> [--label <l>]
  --property "<iri> [string|integer|decimal|boolean|date|datetime|iri]
              [required] [in[v1,v2,...]]" ...
fluree model entity show <dataset>

Compiles to an rdfs:Class node + a sh:NodeShape targeting it, with
stable property-shape ids ({entity}/shape/{localname}) so re-define is
an idempotent upsert. The define output states plainly that validation
becomes ACTIVE (existing data is not retro-validated; fluree validate
reports) and prints the model access enable synergy hint.

Shared ledger-IO helpers (resolve_mode/query/upsert) promoted from
access.rs to model/mod.rs.

Verified locally end-to-end: define (no seed data) → access enable
reports 'Derivation: shacl-shape' (class-exact, 3 properties) → a
shape-violating insert is rejected by SHACL with a violation report →
a conforming insert through the compiled policy class commits → both
show views render.
Third tier of the access compiler: a SPARQL property-path subset
(sequence / and inverse ^ of angle-bracketed absolute IRIs), anchored
identity → path → subject, expanded into f:query where-patterns on the
view policy (f:query replaces f:allow — they are alternative decision
modes). The raw path is recorded as fm:connected on the profile node.

  --connected "^<https://x/owner>"                 # I see what I own
  --connected "<https://x/memberOf>/^<https://x/team>"
                                    # I see entities whose team I'm in

v1 scope: --profile read only. Relationship gates evaluate against
pre-transaction state, so gating writes would deny every create (the
connection triples are not yet visible); staged-state evaluation at
the engine lifts this later. Alternatives (|) and transitive (+/*)
paths are rejected with a clear message until f:query supports them.

Verified live in local mode: two users on different teams, one enable
with --connected, and each user's query returns only their own team's
entities — graph-native row-level security from a single flag.
…nd hierarchy

- 'model access verify' recompiles every stored fm:AccessProfile from its
  intent node and semantically compares against the policies in the
  ledger (compact/full form tolerant). Hand-edited allow-lists, flipped
  f:allow, retargeted classes, and missing/unexpected policies report as
  DRIFT with a nonzero exit for CI.
- 'model access sync' re-derives profiles whose property surface came
  from a source that can change (SHACL shape, observed data) and
  recompiles on drift; explicit lists are never rewritten. The compiler
  now records fm:derivation and fm:allowShared on the intent node so
  sync honors the author's original choices; nodes written before these
  fields (or by other front ends) read as explicit — the safe default.
- 'model class define/show' — rdfs:subClassOf hierarchy as governed
  data, with the entailment blast-radius note; show excludes
  compiler-minted policy classes from the domain vocabulary.

Verified live against a hosted stack (verify caught a hand-widened
allow-list; enable repaired it) and locally (shape grew -> sync applied
'+score' -> verify clean).
Proves the storage form governance tooling uses for relationship gates:
a stored, classed view policy whose f:query is one triple over an @path
term (the author's path expression survives verbatim in the artifact),
selected via policy-class with a bind-only identity. Two identities,
one gate, each sees only entities connected via ex:memberOf/^ex:team.
…caffolder

The engine's f:create/f:update/f:delete verbs (with pre∪post class
targeting and class-scoped rdf:type matching) make thin class policies
exact, so the entire workaround layer is deleted: no property allow-list
derivation, no uniqueness partition / --allow-shared, no fm: intent
vocabulary, no sync, no drift-verify (1,461 → ~700 lines). Profiles map
directly: read → view; write → view + create/update/delete; intake →
create-only. --property remains as optional column narrowing
(f:onProperty conjunction). --connected now stores the SPARQL path
VERBATIM via the engine's @path context term (readable + reversible in
the artifact; show extracts it back).

model entity define gains --closed (sh:closed + rdf:type carve-out):
validation owns the property surface, policy owns state transitions.

Contract proven by integration test
scaffolder_write_profile_grants_class_ownership_only: the scaffolder's
byte-exact output, stored + selected via policy-class with bind-only
identity, allows create/update of the class and denies minting or
editing other classes. @path gate test switched to the CLI's
angle-bracket absolute-IRI storage form.
Property-merge upsert could leave a stale f:allow: true on a policy node
when re-running `model access enable --connected`; the policy loader
gives f:allow precedence over f:query, so the new relationship gate was
silently disabled and the policy stayed allow-all. Install compiled
artifacts through one update transaction that wildcard-deletes every
OWNED node id (delete-if-exists via optional patterns) and inserts the
fresh compilation.

- access: owns {class}/view and {class}/write on every run, so gating
  switches are exact and profile switches on the same class revoke the
  node the new profile no longer emits
- entity: owns the shape and its property-shape children (a dropped
  sh:closed/sh:minCount/sh:in no longer survives a re-run); the entity
  class node is shared authorship with `model class define` and stays
  additive
- class show: recognize policy classes by the extra @type on
  f:AccessPolicy nodes (the fm:AccessProfile intent vocabulary no longer
  exists); correct the --subclass-of re-run hint (replaces, not extends)
- integration test pins the CLI-emitted replace transaction end-to-end:
  an allow-all read profile switched to a --connected gate must narrow
Add docs/cli/model.md covering the three governance facets (access
profiles, SHACL entity shapes, class hierarchy), their compile-to-data
architecture, and the atomic replace semantics of re-running enable and
define. Index it in docs/SUMMARY.md and the CLI command table.
The access command's target restriction compiles to f:onClass, but the
flag was named --entity — which reads as subject-level targeting
(f:onSubject) to RDF users, and clashed with `model class define
--class` taking the SAME iri for the same concept. Rename:

- --entity  → --class        (the governed class; what data)
- --class-iri → --policy-class (the assignment unit grants/tokens carry;
  how a request selects its policy set — not a data restriction)

`model entity define --entity` is unchanged: there the flag names the
entity facet's own concept. Internal names and enable's output follow
(Policy class: line), and the docs spell out the three class-shaped
roles: --class restricts data, --property narrows columns, the policy
class selects the policy set.
@bplatz
bplatz requested review from aaj3f and zonotope July 17, 2026 23:14
@bplatz bplatz mentioned this pull request Jul 17, 2026

@aaj3f aaj3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A few inline notes and then just some test gaps is you want to cover them:

  • no test for the child-id collision (the should-fix bug above)
  • none for the drop-a-property owned-set boundary
  • none for the contradictory-token / in[a,,b] parser cases
  • class.rs has no test for the can't-clear-last-parent limitation or show's policy-class exclusion

Comment on lines +92 to +95
/// Last path segment of an IRI, for stable child-node ids.
fn local_name(iri: &str) -> &str {
iri.rsplit(['/', '#', ':']).next().unwrap_or(iri)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[as well as :113 (child id), and :251-254 (owned set)]

I think there's a silent validation-correctness bug. Child property-shape ids are minted as {shape}/{local_name(path)} where local_name is the last /,#,: segment of the property IRI, with no dedup/collision check. Two property specs whose IRIs share a final segment collapse to the same @id, and JSON-LD unions them into one node.

Traced through the real SHACL compiler: sh:path is last-write-wins (fluree-db-shacl/src/compile.rs:648) so one property's path is silently dropped from validation, while sh:datatype accumulates (compile.rs:667) so the survivor gets two datatypes → an unsatisfiable shape.

Concrete repro:

model entity define Lead \
  --property "https://schema.org/name string required" \
  --property "https://example.org/name integer"

It produces one merged Lead/shape/name; one name is unenforced and the survivor requires every value be both xsd:string and xsd:integer, so every instance is rejected — with no CLI error. Common vocabularies collide on the final segment (name, email, id, status, title across schema.org/foaf/custom), so this is reachable in ordinary multi-namespace models.

Fix is a one-liner: detect a duplicate child id across specs and return CliError::Usage. (A stricter reviewer could hold this as Request-changes; I'm leaving the overall verdict at Approve-with-nits because the trigger is narrow and the fix is trivial — but it should land before merge.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 8c1174f — duplicate child ids are now rejected up front with a CliError::Usage naming both colliding IRIs and the shape id.

Comment on lines +119 to +127
if p.iri_kind {
node[format!("{SH}nodeKind")] = json!({"@id": format!("{SH}IRI")});
}
if p.required {
node[format!("{SH}minCount")] = json!(1);
}
if !p.values_in.is_empty() {
node[format!("{SH}in")] = json!({"@list": p.values_in});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

a spec combining iri with in[...] (or with a datatype token) emits contradictory constraints: sh:in values are serialized as string literals while sh:nodeKind sh:IRI requires an IRI value, so nothing validates; likewise iri + a datatype emits both sh:datatype and sh:nodeKind.

parse_property_spec also accepts two datatype tokens (last wins) and silently drops empty values in in[a,,b], and whitespace inside in[a, b] fails with a cryptic unknown token 'in[a,'.

Consider rejecting the mutually-exclusive combinations and tightening the in[...] tokenizer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 8c1174firi+datatype, a second datatype, and iri+in[...] are now rejected; empty values in in[a,,b] error instead of being dropped. For in[a, b] I kept the whitespace tokenizer but the error now says spaces aren't allowed inside in[...] rather than the cryptic unknown-token message.

Comment on lines +243 to +257
// The shape and its property-shape children are OWNED by this compiler
// and replaced atomically — a constraint from a previous definition
// (sh:closed, sh:minCount, sh:in, …) must not survive a re-run that
// dropped it, or validation keeps enforcing what the author removed.
// The entity class node is NOT owned: it's shared authorship with
// `model class define` (hierarchy, label), so it stays additive.
let mode = resolve_mode(dataset, remote, dirs, direct).await?;
let shape_iri = format!("{}/shape", entity.trim_end_matches('/'));
let mut owned: Vec<String> = specs
.iter()
.map(|p| format!("{shape_iri}/{}", local_name(&p.path)))
.collect();
owned.insert(0, shape_iri);
let owned_refs: Vec<&str> = owned.iter().map(String::as_str).collect();
replace_nodes(&mode, &graph, &owned_refs).await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Very minor but worth considering that there are scenarios that may leave orphaned child shapes on property removal. The owned/replace set is derived from the current specs only, so re-running define with a property dropped never deletes that property's old child shape.

Verified against the compiler that this does not break validation, as orphan children carry no @type sh:NodeShape and aren't reachable from the (fully-replaced) parent's sh:property. So, the dropped constraint does stop firing and closure narrows correctly, but dangling triples (and the sh:in/sh:ignoredProperties RDF-list blank nodes, which a wildcard-delete-by-subject doesn't cascade to) accumulate on every drop/rename.

The :243-248 comment ("property-shape children are OWNED … replaced atomically") over-claims for the removed-child case. If we want a true replace, query the shape's existing sh:property child ids and union them into the owned set; otherwise we can soften the comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 8c1174f — went with the true-replace option: define now queries the shape's existing sh:property children and unions them into the owned set, so a dropped property's child shape is deleted. The RDF-list blank nodes behind sh:in/sh:ignoredProperties are the one residue a wildcard delete-by-subject can't cascade to; the ownership comment now says so.

Comment on lines +92 to +110
/// Compile the class node. `upsert` replaces the listed properties, so
/// re-defining with a different parent set replaces the hierarchy edge
/// rather than accumulating stale ones.
fn compile(class: &str, subclass_of: &[String], label: Option<&str>) -> Value {
let mut node = json!({
"@id": class,
"@type": RDFS_CLASS,
});
if let Some(l) = label {
node[RDFS_LABEL] = json!(l);
}
if !subclass_of.is_empty() {
node[RDFS_SUBCLASS_OF] = json!(subclass_of
.iter()
.map(|p| json!({"@id": p}))
.collect::<Vec<_>>());
}
node
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[as well as mod.rs:69-86 upsert]

class define cannot clear the previous subClassOf parent (or a label) because compile omits rdfs:subClassOf when the parent list is empty, and upsert only replaces listed properties, so there is no way to remove the final parent (no --no-subclass).

This is a footgun precisely because, with RDFS entailment, a stale class parent widens every grant on that parent (class.rs:70-75).

It's semi-surfaced by the printed "replaces the parent set" message but can't actually be executed to empty.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 8c1174f — added --clear-subclass-of (clap-enforced conflict with --subclass-of), which atomically deletes every rdfs:subClassOf edge while writing the node. Kept empty --subclass-of meaning "leave parents alone" so a label-only re-run can't silently wipe the hierarchy.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Relevant to lines 101-107, 149, 251-256

entity define --label is non-idempotent: the class node is inserted-but-not-owned (additive), so re-running with a changed --label adds a second rdfs:label rather than replacing it (unlike class define, which upserts).

Minor, but a user attempting to re-label would leave two labels on the class.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 8c1174f — when --label is given, the transaction now deletes the class node's existing rdfs:label in the same atomic update (targeted property replace); the node otherwise stays additive.

Comment on lines +46 to +50
if !(path.starts_with("http://") || path.starts_with("https://") || path.starts_with("urn:")) {
return Err(CliError::Usage(format!(
"--property must start with an absolute IRI (got '{path}')"
)));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The absolute-IRI check is duplicated three times. Here, at :194-201 and in class.rs:173-181

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 8c1174f — hoisted to mod.rs::require_absolute_iri; there was a fourth copy in access.rs that's deduped too.

Comment thread fluree-db-api/tests/it_policy_verbs.rs Outdated
Comment on lines +745 to +774
// Byte-for-byte the shape `fluree model access enable` emits.
let class = "http://example.org/ns/Lead/access/write";
let stored = json!({
"@context": {"f": "https://ns.flur.ee/db#"},
"@graph": [
{
"@id": class,
"@type": "http://www.w3.org/2000/01/rdf-schema#Class",
"http://www.w3.org/2000/01/rdf-schema#label": "write access: http://example.org/ns/Lead"
},
{
"@id": format!("{class}/view"),
"@type": ["f:AccessPolicy", class],
"f:action": {"@id": "f:view"},
"f:onClass": {"@id": "http://example.org/ns/Lead"},
"f:allow": true
},
{
"@id": format!("{class}/write"),
"@type": ["f:AccessPolicy", class],
"f:action": [
{"@id": "f:create"},
{"@id": "f:update"},
{"@id": "f:delete"}
],
"f:onClass": {"@id": "http://example.org/ns/Lead"},
"f:allow": true
}
]
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

scaffolder_write_profile_grants_class_ownership_only hand-copies the byte-exact compiled shape rather than calling access::compile() (understandable — importing the CLI compiler into a fluree-db-api test would invert the crate dependency). A one-line comment cross-referencing access::tests::write_profile_emits_class_view_and_verb_policy would flag the coupling so a future compile() change doesn't drift from this copy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 8c1174f

- reject child property-shape id collisions (shared local names silently
  merged constraints, dropping one path from validation)
- reject contradictory --property tokens (iri+datatype, double datatype,
  iri+in[...]) and tighten in[...] parsing (no empty values, clear error
  for spaces)
- union existing sh:property children into the owned set so a dropped
  property's child shape is deleted, not orphaned
- add --clear-subclass-of to model class define (upsert cannot remove the
  last parent; stale parents widen grants under RDFS entailment)
- make entity define --label idempotent via targeted property replace
- dedupe require_absolute_iri across facets; share iri_rows
- cross-reference the hand-copied scaffolder shape in it_policy_verbs
@bplatz

bplatz commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Test gaps from the review are covered in 8c1174f: child-id collision rejection, drop-a-property owned-set boundary, the contradictory-token and in[a,,b]/in[a, b] parser cases, the clear-parents transaction (which replaces the can't-clear-last-parent limitation), and show's policy-class exclusion.

@bplatz
bplatz merged commit 764a30e into main Jul 18, 2026
13 checks passed
@bplatz
bplatz deleted the feat/cli-model-access branch July 18, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants