Skip to content

Hierarchical Scope (Contexts as Notes)

Notes usually belong to both a broad life domain (career, software-dev, personal) and a specific context within it (Builder, Vercel, PKM). The naive way to record both is two fields:

# A task — the redundant way
scope: career # life domain
context: "[[Builder]]" # specific project

This is double-entry maintenance. The domain is derivable from the context — Builder is a career project — so scope repeats information the context already implies. Move PKM from software-dev to personal and you have to fix the PKM note and bulk-update every note that hard-coded scope: software-dev.

Bowerbird’s answer needs no new field type. It reuses three things you already have: entity notes with a parent relation, the under operator (see Targeting Model), and relation fields.

Model contexts and domains as real entity notes in a parent hierarchy. A domain is just a root node; a project is a child; a sub-project is a grandchild.

# Contexts/career.md → a root domain
type: context
# Contexts/Builder.md → a project under career
type: context
parent: "[[career]]"
# Contexts/Vercel.md → a sub-project under Builder
type: context
parent: "[[Builder]]"

A note then carries only the leaf context — never a separate scope:

# A task
type: task
status: active
context: "[[Vercel]]" # domain (career) is derivable by walking up

A context type is an ordinary entity with a self-referential parent relation. Tasks get a context relation pointing at it.

{
"types": {
"entity": {
"output_dir": "Entities",
"fields": {
"type": { "value": "entity" }
},
"field_order": ["type"]
},
"context": {
"extends": "entity",
"output_dir": "Contexts",
"recursive": true,
"fields": {
"type": { "value": "context" },
"parent": { "prompt": "relation", "source": "context" },
"aliases": { "prompt": "list", "alias": true, "list_format": "yaml-array", "default": [] }
},
"field_order": ["type", "parent", "aliases"]
},
"task": {
"output_dir": "Tasks",
"fields": {
"type": { "value": "task" },
"status": { "prompt": "select", "options": ["backlog", "active", "done"], "default": "backlog", "required": true },
"context": { "prompt": "relation", "source": "context" }
},
"field_order": ["type", "status", "context"]
}
}
}

Relation storage uses the vault-wide config.link_format (wikilink by default); field-level format is not part of the version 2 field schema.

The leaf is exact; the domain is a subtree walk. Use the under operator (see Targeting Model), which dereferences the context relation and walks the target’s ancestor chain.

Terminal window
# Exact leaf context only
bwrb list --type task --where "context == '[[Vercel]]'"
# Everything in the Builder project (Builder + Vercel + anything deeper)
bwrb list --type task --where "under(context, '[[Builder]]')"
# The ENTIRE career domain, at any altitude
bwrb list --type task --where "under(context, '[[career]]')"

under is inclusive of the direct target: under(context, '[[career]]') matches a note tagged [[career]] directly as well as any descendant. This is exactly what lets you collapse two fields into one — you can still ask “everything in the career domain” without ever storing the domain on the note.

Querying tells you which notes are in a domain; rendering the tree tells you how the domain is shaped. Because contexts are real notes in a parent hierarchy, bwrb list --output tree draws that hierarchy directly:

Terminal window
bwrb list --type context --output tree
└── career
└── Builder
└── Vercel

This is the fastest way to see — or onboard someone else to — the shape of your context tree. --output tree builds the parent hierarchy whenever the matched notes carry parent links, so it works for any entity type modelling a hierarchy this way, not only types marked recursive. (When the result set has no parent links, --output tree falls back to grouping notes by directory.)

Limit the depth with -L/--depth, and order siblings with --sort/--desc:

Terminal window
# Just the top two levels (domains and their immediate projects)
bwrb list --type context --output tree -L 2
# Order siblings by name, descending
bwrb list --type context --output tree --sort name --desc

You can also narrow the tree to a single subtree first, then render it:

Terminal window
bwrb list --type context --where "isDescendantOf('[[career]]')" --output tree

See bwrb list for the full --output tree reference.

Collapsing scope + context into a single context tree removes the redundancy and unlocks transitive queries:

  • No double entry. A note records one fact (its leaf context). The domain is computed, never stored.
  • Reorganize in one place. Re-parent a context note (PKM.parent from [[software-dev]] to [[personal]]) and every dependent query updates automatically — no bulk rewrite of notes.
  • Transitive queries. “Everything in the career domain” is a single under(context, '[[career]]'), not an OR over every leaf.
  • Contexts are first-class notes. Because they’re real notes, they get unlinked-mention audit coverage, backlinks, and graph presence for free, and aliases (see Schema) help with all of those. Rich contexts (Builder, with its own content) and label-like contexts (PKM) cost the same.
  • Validation for free. A task pointing at a non-existent context is flagged by the existing relation-source audit (see Validation and Audit), exactly like any other broken relation.

Migration: collapse a redundant scope field

Section titled “Migration: collapse a redundant scope field”

If your vault already has the redundant scope select alongside a context relation, migrate in three steps. Nothing is lost, because the domain is derivable from the context tree.

1. Make sure your contexts form a tree. Each context note’s parent should point at its domain (or intermediate project):

# Contexts/Builder.md
type: context
parent: "[[career]]"

2. Confirm the domain is already derivable before deleting anything. Pick a domain and verify the right notes come back through the tree alone:

Terminal window
bwrb list --type task --where "under(context, '[[career]]')"

3. Drop the now-redundant field with bulk. Preview first (dry-run is the default), then execute:

Terminal window
# Preview — which notes still carry the redundant scope?
bwrb bulk --type task --where "!isEmpty(scope)" --delete scope
# Apply
bwrb bulk --type task --where "!isEmpty(scope)" --delete scope --execute

Finally, remove the scope field from your schema and run a schema migration (see Migrations) so the field is no longer declared:

Terminal window
bwrb schema diff
bwrb schema migrate --execute

After this, every note carries only its leaf context, and the life domain is a query away at any altitude.