feat(cli): fluree model — governance tooling for access, entities, and class hierarchy#1516
Conversation
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.
aaj3f
left a comment
There was a problem hiding this comment.
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.rshas no test for the can't-clear-last-parent limitation orshow's policy-class exclusion
| /// Last path segment of an IRI, for stable child-node ids. | ||
| fn local_name(iri: &str) -> &str { | ||
| iri.rsplit(['/', '#', ':']).next().unwrap_or(iri) | ||
| } |
There was a problem hiding this comment.
[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.)
There was a problem hiding this comment.
Addressed in 8c1174f — duplicate child ids are now rejected up front with a CliError::Usage naming both colliding IRIs and the shape id.
| 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}); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Addressed in 8c1174f — iri+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.
| // 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?; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| /// 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 | ||
| } |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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}')" | ||
| ))); | ||
| } |
There was a problem hiding this comment.
The absolute-IRI check is duplicated three times. Here, at :194-201 and in class.rs:173-181
There was a problem hiding this comment.
Addressed in 8c1174f — hoisted to mod.rs::require_absolute_iri; there was a fourth copy in access.rs that's deduped too.
| // 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 | ||
| } | ||
| ] | ||
| }); |
There was a problem hiding this comment.
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.
- 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
|
Test gaps from the review are covered in 8c1174f: child-id collision rejection, drop-a-property owned-set boundary, the contradictory-token and |
What
Adds a
fluree modelcommand 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. Profilesread/write/intakecompile 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 viaf:onProperty),--connected <path>(relationship-gated read; the SPARQL property path is stored verbatim as an@pathcontext term insidef: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 therdf:typecarve-out). Defining a shape activates transaction-time validation for its class.model class define|show—rdfs:Class/rdfs:subClassOfhierarchy; with entailment enabled the hierarchy flows through both query and policy, which is why it lives under governance tooling.Design notes
enableinstalls 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 givesf:allowprecedence overf:query, so a stalef:allow: truewould silently disable a newly added--connectedgate. Owning both ids on every run also makes profile switches exact (write → read revokes the write policy).entity definegets the same treatment for the shape + property-shape children (a droppedsh:closed/sh:minCountdoesn't survive a re-run); the entity class node itself stays additive since it's shared authorship withmodel class define.--class(it compiles tof:onClass, andmodel class define --classtakes the same IRI for the same concept);--policy-classnames the assignment unit by its role.rdf:typewrites 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
fluree-db-cli).scaffolder_write_profile_grants_class_ownership_only— the exact emitted shape, stored and selected viapolicy-class, grants class ownership and nothing else;policy_fquery_with_path_context_term— an@pathcontext term inside a storedf:querysurvives 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 indocs/SUMMARY.mdand the CLI command table; picked up automatically by the embeddedfluree docscorpus.