Depending on id-less documents

Point a depends_on entry at a document with no id – a README, a guide, a campaign brief. Then pick exactly which upstream statuses count as done enough to depend on.

Before you start

Some documents live under one namespace and one file: a README, a getting-started guide, a lone campaign brief. They carry no id: field, and they never need one. Until now, depending on one meant giving it an id it did not need.

An entry in depends_on now resolves as exactly one of three forms. ctxgrd picks the right one from how you write the entry – no prefix, no extra syntax.

Step 1: Write the address that fits the target

  1. A document id – ADR-7. Works as it always has.
  2. A bare namespace name – README. Works only when that namespace binds core.singleton, the declaration that it holds one document.
  3. A repo-relative path, including the .md extension – docs/positioning/001-launch.md. Works for any claimed document, id-less or not.

Configure the two namespaces:

[README]
paths = ["README.md"]
rules = ["core.required-headings", "core.singleton"]

[POSITIONING]
paths = ["docs/positioning/**"]
rules = ["core.required-headings"]

Depend on both, each in the form that fits it:

depends_on: [README, docs/positioning/001-launch.md]

README addresses the whole namespace, because [README] binds core.singleton. docs/positioning/001-launch.md addresses one file directly. POSITIONING does not bind core.singleton, so its bare name would not resolve – and a directory of launch documents needs the path to pick one anyway.

$ ctxgrd
ok: 3 documents · 7 rules · 0 diagnostics

Step 2: Recognize the three ways an address fails

Naming a namespace that doesn’t bind core.singleton. ctxgrd names the missing binding and points you at the forms that work:

error[core.dep-resolved]: depends_on entry 'POSITIONING' does not resolve to a document in the run
  --> docs/adrs/001-example.md:5:0
      |
    5 | depends_on: [README, POSITIONING]
      | ^
      |
  help: add `core.singleton` to [POSITIONING].rules, or write the document's path instead: `docs/positioning/001-launch.md`
  note: `POSITIONING` addresses a namespace as a whole, which resolves only when [POSITIONING] binds `core.singleton` — the declaration that it holds one document

Pointing at a file no namespace claims. The path form only reaches documents. A .md file with no id: that no [<NS>].paths glob covers is not a document, and ctxgrd says why:

error[core.dep-resolved]: depends_on entry 'docs/notes/scratch.md' does not resolve to a document in the run
  --> docs/adrs/001-example.md:5:0
      |
    5 | depends_on: [README, docs/positioning/001-launch.md, docs/notes/scratch.md]
      | ^
      |
  help: add a `paths` glob covering it to a namespace in ctxgrd.toml, or an `id:` to its frontmatter
  note: the file exists but no namespace claims it — an unclaimed .md file is not a document (DOC-001)

Name a path that does not exist on disk at all, and the error stays. The note drops – there is no file left to explain.

error[core.dep-resolved]: depends_on entry 'docs/nowhere/does-not-exist.md' does not resolve to a document in the run
  --> docs/adrs/001-example.md:5:0
      |
    5 | depends_on: [docs/nowhere/does-not-exist.md]
      | ^
      |
  help: no document in the run has location `docs/nowhere/does-not-exist.md` — check the spelling, and note the path is repo-relative and includes the `.md` extension

Guessing an id for a document that never had one. An id-less document carries no id at all – not even an internal placeholder. Writing CAMPAIGN-0 in depends_on behaves like naming any id that does not exist:

error[core.dep-resolved]: depends_on entry 'CAMPAIGN-0' does not resolve to a document in the run
  --> docs/adrs/001-example.md:5:0
      |
    5 | depends_on: [README, docs/positioning/001-launch.md, CAMPAIGN-0]
      | ^
      |
  help: create the missing document (`ctxgrd new CAMPAIGN "..."`) or remove `CAMPAIGN-0` from `depends_on`

Use the namespace name or the path instead.

Step 3: Bind core.singleton, and watch for two files claiming one namespace

The bare-namespace form only works once the target namespace binds core.singleton. paths can still list several literal filenames for one document. A CONTRIBUTING.md a repository keeps at its root or under .github/ is one example:

[CONTRIBUTING]
paths = ["CONTRIBUTING.md", ".github/CONTRIBUTING.md"]
rules = ["core.required-headings", "core.singleton"]

If both files exist at once, core.singleton reports the one it does not treat as authoritative:

error[core.singleton]: [CONTRIBUTING] holds one document, but this file and `CONTRIBUTING.md` both claim it
  --> .github/CONTRIBUTING.md

  help: delete this file, or remove `core.singleton` from [CONTRIBUTING].rules if the namespace holds many documents
  note: `CONTRIBUTING.md` matches an earlier [CONTRIBUTING].paths entry, so it is the document `depends_on: [CONTRIBUTING]` addresses

This defaults to an error, so the exit code goes to 1. Set severity = "warning" under [CONTRIBUTING."core.singleton"] to keep it from blocking CI – the exit code then stays 0. The file matching the earlier entry in paths is the one README-style addresses reach. Delete the extra file, or drop core.singleton if the namespace really does hold many documents.

Step 4: Control which upstream statuses count as done

core.dep-status blocks a document at a settled status from depending on one that is not. Its terminal parameter lists the statuses your own namespace treats as settled. By default, every upstream is checked against that same list.

That single list breaks down once you depend on two upstreams with different vocabularies. A checklist’s real end states are sealed and abandoned – both mean the work stopped. Widen terminal to accept abandoned, and a sealed checklist passes. So does an abandoned one, which was never the intent.

satisfied-by names, per upstream namespace, which of its statuses actually count:

[CAMPAIGN."core.dep-status"]
terminal = ["live", "killed"]
satisfied-by = { CHECKLIST = ["sealed"] }

An upstream named in satisfied-by is checked only against its own list. Any upstream left out falls back to terminal, exactly as before you added the table. The check works the same whether or not the documents involved carry an id. This example gives both an id, so the message below stays readable:

error[core.dep-status]: CAMPAIGN-1 is `live` but depends on CHECKLIST-1, whose status `abandoned` is not one of `sealed`
  --> docs/campaigns/001-launch.md:5:0
      |
    5 | depends_on: [CHECKLIST-1]
      | ^
      |
  help: move CHECKLIST-1 to a status [CAMPAIGN."core.dep-status"].satisfied-by accepts for CHECKLIST, or widen that entry

Move the checklist to sealed, and the same configuration reports clean:

$ ctxgrd
ok: 2 documents · 5 rules · 0 diagnostics

terminal keeps its other job. It still decides whether your own document is settled enough for the rule to check its dependencies at all.

Verify what a rule accepts

ctxgrd rules <code> lists every parameter a rule declares, whether you have used it yet or not:

$ ctxgrd rules core.dep-status
Attribute   terminal (config, optional)
Attribute   satisfied-by (config, optional)
Attribute   severity (config, optional: error|warning)

Run it before writing a parameter table, not after. A key it does not list is a typo. ctxgrd now reports that at config-load time, instead of leaving the rule silently inert.

Further reading