Skip to content

coasys/solid-link-language

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Solid Link Language for AD4M

An AD4M link language that gives a Perspective genuine perspective-sync convergence on top of a Solid pod. Solid/LDP is a plain document store with no native causal history, so this language emulates a content-addressed diff-DAG in the pod: every commit is an immutable resource named by its content hash, carrying an ad4m:previous pointer to its parent commit(s). Folding that DAG is the source of truth; the RDF link view is a derived projection.

Two roles

  1. Convergence substrate (source of truth). A content-addressed diff-DAG. Each commit is an immutable resource diffs/diff-<hash>.ttl whose body is a set of link additions, a set of removals (tombstones), and ad4m:previous triples pointing at the parent commit hash(es). Because commits are named by their content and never mutated or deleted, history and concurrent removals survive, and the same hash always denotes the same body.

  2. Derived link cache (projection). A local key-value cache of the folded link set, kept only for fast query/render. It is fully reconstructible from the DAG and is never authoritative.

These map onto the two-channel architecture shared with the other AD4M link languages (Matrix, Nostr, Bluesky): Channel A is the authoritative convergence substrate above (the diff-DAG in diffs/); Channel B is a SHACL-driven projection of subject-class instances into the protocol's human-facing native content, written under views/. See Role B — native projection below — for Solid this is a near-identity projection, which is the whole point.

How convergence works

  • commit(diff) builds a diff-commit whose ad4m:previous are the current DAG heads, hashes it, ingests it into the local DAG, re-folds to refresh the derived cache, and PUTs the immutable diff-<hash>.ttl resource to the pod. Removals are recorded as tombstones carrying the original link's content hash — never as HTTP DELETE.
  • sync() discovers the commit resources in the pod's diffs/ container, then walks the ad4m:previous chain: it fetches each head, follows parent pointers, and pulls any missing ancestors until the local DAG is complete. It re-folds and pushes the pre-sync→post-sync delta through the executor's emitPerspectiveDiff host channel, then returns it. The push is load-bearing: the AD4M runtime discards sync()'s return value (it runs the sync purely for side effects), so inbound peer links become queryable on the perspective only via emitPerspectiveDiff — returning them is not enough. It does not snapshot-diff the container listing or rely on container ETags.
  • Merge is an OR-Set keyed by link content hash: the folded link set is the union of all added links minus those whose hash appears in a tombstone. This makes merges commutative — concurrent branches fold to the same link set, and a concurrent add+remove of the same link converges to removed — regardless of fetch order. The content hash is [source, predicate, target, author, timestamp] and deliberately excludes proof: AD4M's removeLink hands the language an empty-proof tombstone (the executor does not round-trip the original signature), so a proof-in-key tombstone could never match the signed add a peer folded, and removals would never converge. timestamp stays in the key because it does round-trip — matching the sibling nostr/ipfs convention.
  • currentRevision() is derived from the DAG head set, not the container ETag: null when the DAG is empty, the single head's commit hash when there is one head, and a deterministic digest of the sorted head hashes when there are several. It is stable for a given DAG state and across restarts.

Role B — native projection (SHACL-driven)

Channel A above federates the whole perspective as reified ad4m: diff-commit resources. That is perfect for convergence but opaque to a Solid-native client: SolidOS or a generic LDP/RDF browser pointed at diffs/diff-<hash>.ttl sees ad4m:DiffCommit / ad4m:addition / ad4m:linkHash machinery, not a legible "a message that says hello". Role B (Channel B) closes that gap: it renders each SHACL-annotated subject-class instance as a clean, human-facing RDF resource under views/, so a Solid-native app renders it directly.

Solid is the near-identity case (this is deliberate, not a shortcut)

The other link languages translate a subject instance into a foreign content schema — Matrix into an m.room.message, Nostr into a kind-1 event, Bluesky into an app.bsky.feed.post record — because their native surface is a chat/post schema, not RDF. Solid stores RDF, and an AD4M subject-class instance already IS RDF. So Channel B here is a near-identity projection: it does not invent a non-RDF content schema; it re-expresses the same instance as direct subject-predicate-object triples that a Solid-native vocabulary consumer expects.

Concretely, a Flux message that Channel A stores (reified) as

<#add-Qm…> a ad4m:LinkExpression ;
    rdf:subject <flux://msg1> ; rdf:predicate <sioc://content> ;
    rdf:object "literal:string:Hello%20world" ; ad4m:author "did:key:alice" .

is projected by Channel B into the human-facing resource views/view-<slug>.ttl:

<flux://msg1> a sioc:Post ;
    sioc:content "Hello world" ;
    dcterms:creator <did:key:alice> ;
    dcterms:created "2026-07-12T10:00:00.000Z"^^xsd:dateTime .

The literal is decoded, the AD4M reification is gone, and the class + provenance are stated in vocabulary (sioc:, dcterms:) a Solid app reads. Which graph predicate carries each field is decided by the SHACL projection profile: a projection://nativeType annotation on a NodeShape makes the class projectable, and each projected property's projection://field names the RDF predicate the value rides (for Solid, nativeField is a predicate URI). Stock Flux projects with no SDNA annotations via a built-in default profile (flux://body → sioc:content, native type sioc:Post).

Wiring

  • Outbound (commit). After the authoritative diff-commit is published on Channel A, commit folds the diff's additions through the SHACL profiles and PUTs one views/view-<slug>.ttl per matched instance (slug = content hash of the subject URI, so re-projecting overwrites in place). No ad4m: bytes enter these resources; the projection is derived from Role A and never read back as link truth.
  • Inbound (sync). Because native RDF is first-class on Solid, the reverse path is meaningful here (unlike a plain-text protocol, where it only matters for human-typed chat): a human or a non-AD4M Solid app may drop a genuinely native RDF resource into views/ with no backing commit. sync parses those, maps them back to links via the same SHACL profile, and enters them into Channel A as new authoritative links (a real commit). Resources produced by our own projection are already present on Channel A and are skipped, so the loop is idempotent and never double-ingests.

What Channel B actually adds for Solid

Honest accounting, since Solid's substrate is already RDF:

  • It is not redundant, but it is narrow. Channel A already persists the full graph as native RDF — but as reified diff-commits, which are convergence plumbing, not app-legible resources. Channel B's contribution is exactly the transform from that reified, hash-addressed envelope to a flat, vocabulary-native, human-facing resource keyed on the instance's own subject URI. That view is what makes the data usable by SolidOS and generic LDP tooling.
  • The projection core is shared verbatim. src/projection/ is copied unmodified from the reference (Matrix) language; only src/solid-projection.ts (the NativeAdapter<RdfResource>) is Solid-specific, and it is a thin RDF-resource ⇄ Projection mapping. This keeps Solid consistent with the other languages' Channel-B contract at essentially zero divergence.
  • No lossy foreign schema. Because the mapping is near-identity, the outbound projection loses nothing structural (only the ad4m: provenance reification, which by design lives on Channel A) and the inbound ingest reconstructs the instance's links exactly.

Template Variables

Variable Description
SOLID_POD_URL Solid pod base URL
SOLID_CONTAINER_PATH Container path within the pod
SOLID_IDP_URL Identity Provider URL
SOLID_WEBID WebID for authentication
NEIGHBOURHOOD_META AD4M neighbourhood metadata

Building

NODE_ENV=development pnpm install
deno run --allow-all esbuild.ts

Requires @coasys/ad4m-ldk at ../ad4m/ad4m-ldk/js/ or set AD4M_LDK_ENTRY.

Testing

node --experimental-vm-modules --import tsx --test tests/*.test.ts

The suite unit-tests the full convergence path against in-memory fixtures — no live pod required:

  • tests/diffdag.test.ts — the acceptance criteria: commit-hash determinism and order-independence; revision derivation (empty / single head / multi-head digest) and head computation; the DAG being authoritative (folding the ad4m:previous chain from genesis reproduces the link set, tombstones remove, re-adds stay removed); order-independent merge; Turtle round-trip preserving the original link hash in a tombstone.
  • tests/sync.test.ts — the DAG-walk sync against a mock pod: ancestry walk re-requesting missing parents, incremental diffs, idempotent re-sync, and removal convergence via tombstones. Also asserts the emit contract via a spy runtime — a non-empty fold pushes exactly one emitPerspectiveDiff carrying the peer links, an empty fold emits nothing, and a tombstone fold emits a removal delta (the invariant a silent no-op mock previously let slip, surfacing live as the C1 A=10/B=10 freeze).
  • tests/ldp.test.ts — the content-hash resource URL builders (diffsContainerUrl, diffResourceUrl, extractCommitHash) and header/URL helpers, plus the joinPodPath slash-normalisation matrix: a no-trailing-slash pod URL joined to a no-leading-slash container path must resolve to a new URL()-parseable string with exactly one separator (not the glued …3005ad4m/… that threw on every fetch and froze C1 at A=10/B=10).
  • tests/cross-runtime.test.ts — revision derivation through the store.
  • tests/projection.test.ts — Channel B core: the literal codec, node-expression evaluator, SHACL profile parsing, project/ingest round-trip, and the Solid RDF adapter (toNative emits a clean sioc:Post resource with no ad4m: reification; fromNative parses it back; typed literals / URI objects).
  • tests/channel-b-bridge.test.ts — Channel B orchestration over the Solid adapter: projectInstances (graph → native RDF resources), ingestNative (native resource → authoritative links, with container parenting), and the default Flux → SIOC message profile.

Not covered by the unit suite: WebID-OIDC / CSS token authentication and Web Access Control. Live LDP container creation and PUT/GET round-trips against a running Community Solid Server are exercised by the co-located C1 run below.

Verified live against a real pod (co-located C1)

Beyond the unit fixtures, this language has been run end-to-end against a live Community Solid Server 7.1.9 (in-memory storage, allow-all authorization) in the wind-tunnel c1-convergence scenario: two AD4M executors PUT/GET diff-commit resources into one shared pod container. Both agents reached 20/20 links in 1.0 s and a removal converged in 3.0 s.

That run earned its keep — it surfaced three defects invisible to the 282 passing unit tests, each dependent on live two-agent semantics against a real pod: (1) the executor discarding sync()'s return value — the fold walked the pod's ad4m:previous DAG and updated the local store but returned the delta instead of pushing it, so peer links never became queryable and each replica froze at its own 10 links; fixed by routing the fold delta through emitPerspectiveDiff; (2) a URL-join bug gluing a no-trailing-slash pod URL to a no-leading-slash container path into an invalid …3005ad4m/… that threw on every fetch, now normalised through a joinPodPath helper; and (3) an OR-Set identity-key bug — the link hash keyed on proof, but AD4M hands the language an empty-proof tombstone, so a removal could never reference the signed add a peer had folded; fixed by dropping proof from the key. Regression tests for all three lock the behaviour (the emit-contract, slash-normalisation, and proof-stripped tombstone tests above).

Role B — verified live in a Solid-native RDF reader

The run above proves Channel-A convergence. Channel B was separately verified end-to-end against the same live CSS 7.1.9: this language's own projection path PUT a views/view-<slug>.ttl, and three independent RDF stacksrdflib, N3.js, and a SPARQL parse — each read it back as a clean sioc:Post (sioc:content "Hello world", dcterms:creator, dcterms:created) with zero ad4m: triples. The reverse path was exercised too: a hand-authored native RDF resource dropped into views/ with no backing commit was parsed back into a new authoritative link on Channel A.

Both honesty checks hold: (1) the projection is real — legible vocabulary-native RDF a Solid app reads directly, not ad4m:DiffCommit machinery; (2) it is live — this language's path wrote it and native RDF readers rendered it. Caveat: the pod ran allow-all authorization, so WebID-OIDC / Web Access Control gating of the views/ container is not part of this run (as noted above for Channel A).

Module map

  • src/diffdag.ts — the convergence substrate: link/commit content hashing, head computation, revision derivation, OR-Set fold, commit ↔ Turtle serialization (with ad4m:previous). Pure; no adapter or host imports.
  • src/store.ts — the DAG cache (source of truth: dag/commit/*, dag/heads) and the derived link cache, plus query and peer state.
  • src/sync.ts — the pod-side DAG walk: discover commit resources, follow ad4m:previous, fetch missing ancestors, re-fold.
  • src/ldp.ts — LDP GET/HEAD/PUT/POST/PATCH/DELETE and the resource-URL builders for the diffs/ container (diff-<hash>.ttl) and the Channel-B views/ container (view-<slug>.ttl).
  • src/ontology.ts — the AD4M diff-DAG ontology terms in RDF (ad4m:DiffCommit, ad4m:previous, ad4m:addition, ad4m:removal, ad4m:Tombstone, …).
  • src/rdf.ts — Turtle parsing/serialization primitives.
  • src/projection/ — the shared, protocol-agnostic Channel-B core, copied verbatim from the reference (Matrix) language: literal codec, node-expression evaluator, SHACL profile parsing, and the project/ingest/projectInstances/ ingestNative fold. Not modified per-language.
  • src/solid-projection.ts — the Solid-specific NativeAdapter<RdfResource>: the only Channel-B code that knows Solid's native shape. toNative renders a Projection as a flat, vocabulary-native RDF resource (subject + direct predicate/object triples, dcterms: provenance); fromNative parses one back. The near-identity mapping that distinguishes Solid from the plain-text protocols.
  • src/translate.ts — link ↔ RDF translation, plus the retained dual-language and SDNA-pattern helpers (exercised by tests/dual-language.test.ts and tests/sdna.test.ts). These are orthogonal to the diff-DAG contract and are not wired into the append-only commit path.
  • src/acl.ts — Web Access Control resource management.
  • src/auth.ts — WebID-OIDC / CSS token authentication.
  • src/settings.ts — language settings parsing.
  • src/types.ts — shared link/diff/perspective types.
  • src/adapters.ts / src/adapters-deno.ts — the injected Transport / Storage / Runtime / Signing adapters. ad4m:host imports are confined to adapters-deno.ts and index.ts, so every core module is testable across runtimes.

License

CAL-1.0

About

AD4M Link Language for solid

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors