Skip to content

Progressive Enhancement — one surface, additive features

This is the conceptual overview of the single most important architectural decision in Trails. The normative text lives in ADR-0021; this page is the short, prose-shaped explanation that the vision, the README, and the architecture and design-spec documents point at.

The one-line version

Trails exposes one surface. Start with nodes and edges; add types, shapes, and OWL when you need them. Code written on day one keeps working on day 365 — new features are never rewrites.

Why "progressive" and not "tiered"

An earlier draft (ADR-0020) proposed three explicit tiers — T1 plain KG, T2 typed KG, T3 full semweb — with separate module namespaces (trails.kg, trails.kg.types, trails) and a tier field on every capability. Writing the supporting amendments surfaced that the framing added more complexity than it removed:

  • Users had to pick a tier at project start.
  • Cedar needed three entity-type prefixes with "deliberately verbose" cross-tier policies.
  • The provenance graph was always RDF anyway, so T1 was a veneer.
  • Three namespaces to remember and bridge.

Rails solved this the other way: one ActiveRecord surface, with validations, associations, and callbacks as additive features. Users never pick a "Rails tier." Trails does the same.

The four rungs

Every Trails app sits on some subset of the same ladder. All four rungs use the same @capability decorator, the same ctx, the same invoke():

Rung 1 — labels only

from trails import capability


@capability
def create_note(ctx, title: str, body: str) -> dict:
    iri = ctx.kg.add({"title": title, "body": body})
    return {"id": iri}

No IRI design, no shape, no ontology. The framework still emits PROV-O provenance, enforces cost envelopes, and logs Cedar policy decisions — it just does so against a label-only node. Works on a Tuesday afternoon; works forever.

Rung 2 — @node_type for JSON-Schema validation

from trails import capability, node_type


@node_type("Note", fields={"title": str, "body": str})
class Note:
    pass


@capability
def create_note(ctx, title: str, body: str) -> dict:
    n = Note(title=title, body=body)
    ctx.kg.add(n)
    return {"id": n.id}

The Rung-1 capability above is still valid in the same app. The only change is that writes to Note-labelled nodes now get JSON-Schema validation on the field types.

Rung 3 — @shape for closed-world SHACL

from trails import capability, shape, predicate


@shape
class Note:
    title: str = predicate("schema:name", min_length=1)
    body:  str = predicate("schema:text")

Now Cedar, SHACL, and PROV-O can all reason about the shape IRI and its predicates. trails onto export materialises the shape into ontology/generated.ttl. Existing Rung-1 and Rung-2 code is untouched.

Rung 4 — OWL for reasoning and cross-system interop

# ontology/vocab.ttl
@prefix :     <https://myapp.example/ns/> .
@prefix owl:  <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

:PublishedNote rdfs:subClassOf :Note .
:Note          a owl:Class .

No handler change. The reasoner feature-detects owl:Class + rdfs:subClassOf in the loaded ontology and begins materialising entailments on the affected named graphs (see ADR-0004). Query with SPARQL; publish the ontology for other systems to consume.

Strongest-available-type matching

Under the hood, Cedar, PROV-O, and SHACL all inspect each entity and act on whatever typing is present: RDF class > SHACL shape > JSON-Schema type > label. One code path, feature-detected at dispatch time. This is the rule formalised by ADR-0022.

A single Cedar policy can write resource is Note and match on all four rungs without three separate entity-type prefixes:

  • Rung 1: resource.labels contains "Note" matches.
  • Rung 2: resource.type == "Note" matches on the node-type name.
  • Rung 3: resource.shape == <myapp:Note> matches on the SHACL IRI.
  • Rung 4: resource.rdf_type == <myapp:Note> matches on the RDF class.

Policy authors write one rule; the framework resolves whichever typing the resource actually carries. The same principle applies to SHACL (it only runs where a shape is declared) and to PROV-O (it emits trails:typing hints based on what's present).

What this buys you

  • No decision at project start. Write the handler; feature-decide later. The demo loop (trails new blog && trails server) drops you straight onto Rung 1.
  • Additions, not rewrites. Adding @node_type does not change storage. Adding @shape does not change storage. Adding OWL does not change storage. Existing data keeps working.
  • Opt-in trust stack. Cedar policy, DIDs + VCs, PROV-O always on, SHACL, OWL reasoning, consent receipts, ACT/biscuit capability tokens, replayable traces, cost envelopes — all first-class, all opt-in. You don't pay for them until you reach for them, and you don't re-architect when you do.
  • One module to learn. trails.capability, trails.node_type, trails.shape, trails.policy, trails.kg on ctx. No tier-split namespaces, no "choose your tier" doc.

What it costs

  • Harder to explain in a tweet. "Pick T1 for speed, T3 for compliance" is catchy. "One surface, opt into what you need" is slightly less so — and significantly easier to use.
  • Feature-detection everywhere. Cedar, SHACL, and PROV-O each carry a strongest-available-type resolver. The kernel pays the branch cost; the author never sees it.
  • No "freeze this capability at labels-only" declaration. If you want to guarantee a capability never grows past labels, you rely on conventional review (and on Cedar policies that reject writes carrying shape IRIs you didn't expect). There is no descriptor flag for it.
  • Vision — the narrative framing of the same ladder, written for a first-time reader.
  • docs/tutorials/growing-your-kg-app.md — the hands-on walkthrough that takes you rung-by-rung from an empty project to a reasoning-enabled KG app.
  • ADR-0021 — the normative decision record this page summarises, with its full consequences, alternatives considered, and migration plan.
  • ADR-0022 — the strongest-available-type rule at the policy layer.
  • 02-architecture.md — where each rung is implemented in the kernel + surface split.
  • 03-design-spec.md §3.3.1 — the four rungs side by side in API form.