Vancetope — Document References
A Document Reference addresses a document from within another document — in a Recipe, Skill, Guard, Embed, Link. There is one grammar for this, deterministically (no LLM) resolved by a central
DocumentRefResolver. On a web interface, the same grammar is used as a URI with schemavance:; in Config/YAML, it’s a bare path without a schema. The path part is identical.
See also: script-document-api ( vance.documents.*)shooty (first consumer) doc-kind-workpage / app-binder ( vance:-Embeds)inter-links ( ?entry=)
1. Grammar — RFC-3986 Reference Resolution
A reference is a URI reference and is resolved against the Base vance://<currentProject>/<referrerDir>/ — according to RFC 3986, with one explicit deviation: the vance: schema is optional and does not make the reference absolute (see below).
| Form | Meaning |
|---|---|
path |
relative — to the directory of the referring document (“next to the Skill”). |
/path |
absolute within the current Project (from its root). |
//projectId/path |
the same path in a different Project (Authority = Project name, unique). |
vance:path · vance:/path · vance://projectId/path |
the same thing with schema. |
project == projectId is the Project name (not the Mongo id) — consistent with the entity convention where *Id fields reference the name. The URI Authority is the Project name.
The schema is a marker, not a mode. vance: says “this is one of our references” and nothing else; what follows it decides — vance:foo is relative, just like foo, vance:/foo is absolute, vance://p/foo is a foreign Project. This means there are three forms, not six, and the rule can be stated in one sentence: Slashes decide, schema does not.
This deliberately deviates from RFC 3986, where a schema makes the reference absolute. The reason is that a reference here is always written from within a document, and “the file next to it” is the most common case; an author who prepends vance: to distinguish the reference would otherwise silently shift its target. To get the RFC interpretation, write vance:/foo — that’s one more character and visible.
1.1 Known Query Parameters
The query is not part of the addressing — it never changes which document is meant. It tells the consumer what to do with it and is therefore passed through unchanged to DocumentRef.query. A consumer that does not recognize a parameter ignores it.
| Parameter | Reader | Meaning |
|---|---|---|
kind |
Embed/Kind Renderer | Render hint if the target’s Kind is insufficient or not set |
entry |
the addressed Application | a location within the app — see inter-links |
mode, caption |
Markdown Embeds | Display (preview/reference), caption |
entry is the only parameter whose value no one but the target interprets: it is an opaque, app-specific handle. It must be percent-encoded when created — foreign text with & or = would otherwise break the query — and it is neither validated nor resolved during resolution; whether it still means something is decided by the app when opened.
2. Resolution
The DocumentRefResolver (vance-shared, @Service, pure — no I/O, no document store):
resolve(ref, DocumentRefContext{currentProjectId, referrerDir}) → DocumentRef{projectId, path, query}
#fragmentis discarded,?queryis separated and returned onDocumentRef.query(see §1.1 — consumers that need it do not re-parse it).- Optional
vance:schema is only stripped — no slash added, no special treatment; from here on, a reference with a schema is indistinguishable from one without. //authority/path→ different Project;/path→ current Project from Root;path→ relative toreferrerDir.- Canonicalization:
./..collapsed, empty and double separators removed; a..beyond the Project root is aDocumentRefException(no silent escape).
DocumentRefContext.root(project) for Config that addresses from the Root; DocumentRefContext.fromReferrerDocument(project, referrerDocPath) sets referrerDir to the parent folder of the referring document (thus guard.js next to _vance/skills/x/skill.yaml becomes _vance/skills/x/guard.js).
2.1 Same Resolution in the Browser
A vance: link in a rendered document is resolved client-side — the server never sees it. parseVanceUri (vance-face) is the twin of the Resolver: the same three forms, the same canonicalization, the same abortion when breaking out of the Project root.
What it lacks is the referrer: the renderer receives a Markdown string and does not know which document it originated from. This information therefore comes from the host via provide/inject (DOCUMENT_REFERRER_KEY, provideDocumentReferrer(pathRef)) and is deliberately declared per document, not per page:
| Provider | Declares |
|---|---|
Cortex Tab (DocumentTabShell) |
the tab’s document |
EmbeddedKindBox |
the embedded document — references within it are next to it, not next to the host |
| none (Chat, Inbox, search result) | empty = Project root |
The last case is the correct answer and not a fallback: a chat text does not belong to any document, so there is nothing it could be relative to. This is precisely why it is provide and not a property — the innermost provider wins at every nesting depth, and a property would have to be passed through every intermediate component.
Boundary: only the path travels, not the Project. A relative reference within a document embedded from a foreign Project still lands in the current Project.
3. Enforcement Remains at the Call Site
The Resolver only calculates the target (projectId, path) — a cross-project reference implies no access. The caller loads the document via its normal path and applies the usual permission check to the resolved Project (READ, analogous to foreign_*). Separation as in the Permission System: Resolver = pure calculation, Enforcement = separate. Where a consumer has operator-trusted input (Recipe/Runtime-Guard-Config), it can waive the check — this is a call-site decision, not a Resolver property.
4. Consumers
The Resolver is available as an @Service (resolve) and as a pure static method (resolveRef) — static call sites that cannot hold a Bean share the same logic.
Consumers (v1):
- Shooty-Guard script loader — Guard script references relative to the Project root, so that
/absoluteand//other-project/...work (injected Bean). ScriptDocumentApi(vance.documents.read/write/exists/delete/meta) — Single document access viaresolveRef(relative todocumentBasePath), with same-project-Clamp: a cross-project reference is rejected (Sandbox invariant),..is canonicalized (hardening).
Deliberately NOT targets (no authored ref or different semantics — forcing would change behavior or require hacks):
ScriptDocumentApi.list(prefix)— the prefix is astartsWith-match where the trailing slash is significant (notes/≠notes). Canonicalization would strip it → remains literal.BinderResolver.stripToPath— a project-agnostic, same-project, URL-decoding path reducer (static, widely used). Deliberately discards the Authority and percent-decodes (vance:-URIs are encoded). A true migration would require a placeholder Project context + would treat//-Refs differently — no clean gain. Cross-project Binder entries would be a feature, not a refactor.- Fixed-path
lookupCascade-Loader (Recipe/Skill/Manual/…) — load fixed internal paths (_vance/recipes/<name>.yaml), no authored Refs; nothing to resolve.
5. Anchors (Current Code)
vance-shared/.../document/DocumentRefResolver.java(+DocumentRef,DocumentRefContext,DocumentRefException).- First consumer:
vance-brain/.../guard/CompletionGuardService.java(loadScript).