diff --git a/README.md b/README.md index f708f0d..2ec7147 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,11 @@ authentication or membership authority. See [`mdbase.person` 1.0.0](contracts/mdbase.person/1.0.0.md) for matching, ambiguity, privacy, and lifecycle semantics. +The `mdbase.comment` pack defines comments, replies and suggested edits as +records of their own, anchored to a quote of the commented record's Markdown +body and linked to `mdbase.person` authors. Apps that comment embed the +published provision unchanged, as mdbase writer does. + The `tasknotes.task` pack is the canonical application-provisioned TaskNotes contract bundle. TaskNotes clients embed the published provision byte-for-byte and pin its catalog digest so independently deployed clients cannot drift onto diff --git a/contracts/mdbase.comment/1.0.0.md b/contracts/mdbase.comment/1.0.0.md new file mode 100644 index 0000000..fa7df1e --- /dev/null +++ b/contracts/mdbase.comment/1.0.0.md @@ -0,0 +1,73 @@ +--- +kind: mdbase.contract +contract_type: record +id: mdbase.comment +version: 1.0.0 +name: Comment +description: Comments, replies and suggested edits anchored to text in Markdown records. +record_schema: + dialect: json-schema-2020-12 + ref: ../../schemas/mdbase.comment/1.0.0.schema.json +--- + +# Comment + +A comment is its own record, never markup inside the commented record. The +commented record's Markdown stays exactly as its author wrote it, and comments +can be queried, linked to and validated like any other record. The comment's +Markdown body is its text; a link to a person in that text is a mention. + +## Threads + +The first comment of a thread has no `in_reply_to`. It carries the thread's +anchor (`target`) and state (`status`, `resolved_by`, `resolved_at`). Each reply +is a separate record whose `in_reply_to` links to that first comment, so two +people replying at once write two records and never conflict. Replies repeat +the thread's `document`. Order a thread's replies by `created_at`. + +Refer to records only with ordinary mdbase links: `document` to the commented +record, `in_reply_to` to the first comment, `created_by` and `resolved_by` to +records implementing `mdbase.person`. Implementing types declare these fields in +`collection.links` so rename reference updates keep them current. Never refer +to an author by name, email or account subject. + +## Anchors + +`target.quote` is the source of truth: `exact` is the anchored text and +`prefix` and `suffix` are the text around it. `target.text_position` records +where the quote was in one revision of the body, in Unicode code points of the +record's Markdown body as stored, after its frontmatter (profile +`markdown-body`). `basis.hash` is `sha256:` and the hex SHA-256 of that body's +UTF-8 bytes. + +To find a thread's text: + +1. When the body at the recorded offsets is still `exact`, use them. +2. Otherwise search the body for `exact`, preferring the occurrence whose + surrounding text best matches `prefix` and `suffix`, then the one nearest + the recorded offsets. +3. When `exact` is not found, the thread is detached. Show it apart from the + text rather than guessing a place. A detached thread is not an error. + +An empty `exact` is an insertion point between `prefix` and `suffix`. A thread +without a `target` is about the whole record. A consumer may rewrite +`text_position` after finding the quote elsewhere; it must not change `quote`. + +## Suggested edits + +A comment with `motivation: editing` suggests replacing the target's quote with +`suggestion.replacement`; an empty replacement suggests deleting it. Accepting +a suggestion first finds the quote as above and must refuse, leaving the +thread open, when the quote cannot be found exactly. It then replaces the +quote in the commented record and resolves the thread with +`suggestion.outcome: accepted`. Rejecting resolves it with `rejected`. + +## Lifecycle + +A comment whose `document` no longer resolves is kept, not deleted: records +can be restored, and the discussion is still collection data. Consumers may +hide such comments. Withdrawing a comment sets `deleted_at` and empties its +body rather than deleting the record, so replies to it keep their thread. + +Authorship links are editable collection data with the same trust as any other +field. They never prove who wrote a comment or grant anyone access. diff --git a/dist/artifacts/contracts/mdbase.comment/1.0.0.md b/dist/artifacts/contracts/mdbase.comment/1.0.0.md new file mode 100644 index 0000000..fa7df1e --- /dev/null +++ b/dist/artifacts/contracts/mdbase.comment/1.0.0.md @@ -0,0 +1,73 @@ +--- +kind: mdbase.contract +contract_type: record +id: mdbase.comment +version: 1.0.0 +name: Comment +description: Comments, replies and suggested edits anchored to text in Markdown records. +record_schema: + dialect: json-schema-2020-12 + ref: ../../schemas/mdbase.comment/1.0.0.schema.json +--- + +# Comment + +A comment is its own record, never markup inside the commented record. The +commented record's Markdown stays exactly as its author wrote it, and comments +can be queried, linked to and validated like any other record. The comment's +Markdown body is its text; a link to a person in that text is a mention. + +## Threads + +The first comment of a thread has no `in_reply_to`. It carries the thread's +anchor (`target`) and state (`status`, `resolved_by`, `resolved_at`). Each reply +is a separate record whose `in_reply_to` links to that first comment, so two +people replying at once write two records and never conflict. Replies repeat +the thread's `document`. Order a thread's replies by `created_at`. + +Refer to records only with ordinary mdbase links: `document` to the commented +record, `in_reply_to` to the first comment, `created_by` and `resolved_by` to +records implementing `mdbase.person`. Implementing types declare these fields in +`collection.links` so rename reference updates keep them current. Never refer +to an author by name, email or account subject. + +## Anchors + +`target.quote` is the source of truth: `exact` is the anchored text and +`prefix` and `suffix` are the text around it. `target.text_position` records +where the quote was in one revision of the body, in Unicode code points of the +record's Markdown body as stored, after its frontmatter (profile +`markdown-body`). `basis.hash` is `sha256:` and the hex SHA-256 of that body's +UTF-8 bytes. + +To find a thread's text: + +1. When the body at the recorded offsets is still `exact`, use them. +2. Otherwise search the body for `exact`, preferring the occurrence whose + surrounding text best matches `prefix` and `suffix`, then the one nearest + the recorded offsets. +3. When `exact` is not found, the thread is detached. Show it apart from the + text rather than guessing a place. A detached thread is not an error. + +An empty `exact` is an insertion point between `prefix` and `suffix`. A thread +without a `target` is about the whole record. A consumer may rewrite +`text_position` after finding the quote elsewhere; it must not change `quote`. + +## Suggested edits + +A comment with `motivation: editing` suggests replacing the target's quote with +`suggestion.replacement`; an empty replacement suggests deleting it. Accepting +a suggestion first finds the quote as above and must refuse, leaving the +thread open, when the quote cannot be found exactly. It then replaces the +quote in the commented record and resolves the thread with +`suggestion.outcome: accepted`. Rejecting resolves it with `rejected`. + +## Lifecycle + +A comment whose `document` no longer resolves is kept, not deleted: records +can be restored, and the discussion is still collection data. Consumers may +hide such comments. Withdrawing a comment sets `deleted_at` and empties its +body rather than deleting the record, so replies to it keep their thread. + +Authorship links are editable collection data with the same trust as any other +field. They never prove who wrote a comment or grant anyone access. diff --git a/dist/artifacts/schemas/mdbase.comment/1.0.0.schema.json b/dist/artifacts/schemas/mdbase.comment/1.0.0.schema.json new file mode 100644 index 0000000..ae810d6 --- /dev/null +++ b/dist/artifacts/schemas/mdbase.comment/1.0.0.schema.json @@ -0,0 +1,229 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/contracts/schemas/mdbase.comment/1.0.0.schema.json", + "title": "Comment", + "description": "A comment, reply or suggested edit on a Markdown record, stored as its own record. The comment's Markdown body is its text.", + "type": "object", + "required": [ + "document", + "created_at" + ], + "properties": { + "document": { + "type": "string", + "pattern": "\\S", + "description": "Link to the commented record, such as [[chapters/method]]. Replies repeat their thread's document so one query finds a record's whole discussion." + }, + "in_reply_to": { + "type": "string", + "pattern": "\\S", + "description": "Link to the first comment of the thread this comment replies to. Absent on the first comment of a thread." + }, + "motivation": { + "enum": [ + "commenting", + "replying", + "editing" + ], + "default": "commenting", + "description": "commenting starts a thread, replying answers one, and editing suggests replacing the target's text." + }, + "target": { + "type": "object", + "description": "Where in the document the thread is anchored. Absent means the whole record. Only the first comment of a thread has a target.", + "properties": { + "quote": { + "type": "object", + "description": "The anchored text and the text around it. The quote is authoritative; positions are a hint.", + "required": [ + "exact" + ], + "properties": { + "exact": { + "type": "string", + "description": "The anchored text exactly as it appears in the record body. Empty for an insertion point, which prefix or suffix then locates." + }, + "prefix": { + "type": "string", + "description": "Text immediately before exact, to tell repeated occurrences apart." + }, + "suffix": { + "type": "string", + "description": "Text immediately after exact, to tell repeated occurrences apart." + } + }, + "if": { + "required": [ + "exact" + ], + "properties": { + "exact": { + "type": "string", + "maxLength": 0 + } + } + }, + "then": { + "anyOf": [ + { + "required": [ + "prefix" + ], + "properties": { + "prefix": { + "type": "string", + "minLength": 1 + } + } + }, + { + "required": [ + "suffix" + ], + "properties": { + "suffix": { + "type": "string", + "minLength": 1 + } + } + } + ] + }, + "additionalProperties": false + }, + "text_position": { + "type": "object", + "description": "Offsets of the quote in one revision of the record body.", + "required": [ + "basis", + "unit", + "start", + "end" + ], + "properties": { + "basis": { + "type": "object", + "required": [ + "profile", + "hash" + ], + "properties": { + "profile": { + "const": "markdown-body", + "description": "Offsets count the record's Markdown body as stored, after the frontmatter, so frontmatter edits never move them." + }, + "hash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "SHA-256 of the UTF-8 body the offsets were measured in." + } + }, + "additionalProperties": false + }, + "unit": { + "const": "unicode_code_point" + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + } + }, + "required": [ + "quote" + ], + "additionalProperties": false + }, + "suggestion": { + "type": "object", + "description": "The suggested edit of an editing comment: replace the target's quote with replacement.", + "required": [ + "replacement" + ], + "properties": { + "replacement": { + "type": "string", + "description": "The text to put in place of the quote. Empty suggests deleting it." + }, + "outcome": { + "enum": [ + "accepted", + "rejected" + ], + "description": "What happened to the suggestion once its thread was resolved." + } + }, + "additionalProperties": false + }, + "status": { + "enum": [ + "open", + "resolved" + ], + "default": "open", + "description": "Whether the thread is still open. Only meaningful on the first comment of a thread." + }, + "resolved_by": { + "type": "string", + "pattern": "\\S", + "description": "Link to the mdbase.person record of who resolved the thread." + }, + "resolved_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "pattern": "\\S", + "description": "Link to the mdbase.person record of the comment's author. Absent when the author has no person record." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "modified_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": "string", + "format": "date-time", + "description": "When the comment was withdrawn. The record stays so its thread and replies keep their shape; its body should be emptied." + } + }, + "allOf": [ + { + "if": { + "properties": { + "motivation": { + "const": "editing" + } + }, + "required": [ + "motivation" + ] + }, + "then": { + "required": [ + "target", + "suggestion" + ], + "properties": { + "target": { + "type": "object" + }, + "suggestion": { + "type": "object" + } + } + } + } + ], + "additionalProperties": false +} diff --git a/dist/artifacts/types/comment/1.md b/dist/artifacts/types/comment/1.md new file mode 100644 index 0000000..fc439af --- /dev/null +++ b/dist/artifacts/types/comment/1.md @@ -0,0 +1,235 @@ +--- +kind: mdbase.type +name: comment +version: 1 +description: A comment, reply or suggested edit on another note, anchored to a passage of its text. +match: + where: + type: comment +schema: + dialect: json-schema-2020-12 + value: + $schema: https://json-schema.org/draft/2020-12/schema + title: Comment + description: A comment, reply or suggested edit on a Markdown record, stored as its own record. The comment's Markdown body is its text. + type: object + required: + - type + - document + - created_at + properties: + type: + const: comment + description: Identifies this note as a Comment. Set by the application that creates it. + document: + type: string + pattern: \S + description: Link to the commented record, such as [[chapters/method]]. Replies repeat their thread's document so one query finds a record's whole discussion. + in_reply_to: + type: string + pattern: \S + description: Link to the first comment of the thread this comment replies to. Absent on the first comment of a thread. + motivation: + enum: + - commenting + - replying + - editing + default: commenting + description: commenting starts a thread, replying answers one, and editing suggests replacing the target's text. + target: + type: object + description: Where in the document the thread is anchored. Absent means the whole record. Only the first comment of a thread has a target. + properties: + quote: + type: object + description: The anchored text and the text around it. The quote is authoritative; positions are a hint. + required: + - exact + properties: + exact: + type: string + description: The anchored text exactly as it appears in the record body. Empty for an insertion point, which prefix or suffix then locates. + prefix: + type: string + description: Text immediately before exact, to tell repeated occurrences apart. + suffix: + type: string + description: Text immediately after exact, to tell repeated occurrences apart. + if: + required: + - exact + properties: + exact: + type: string + maxLength: 0 + then: + anyOf: + - required: + - prefix + properties: + prefix: + type: string + minLength: 1 + - required: + - suffix + properties: + suffix: + type: string + minLength: 1 + additionalProperties: false + text_position: + type: object + description: Offsets of the quote in one revision of the record body. + required: + - basis + - unit + - start + - end + properties: + basis: + type: object + required: + - profile + - hash + properties: + profile: + const: markdown-body + description: Offsets count the record's Markdown body as stored, after the frontmatter, so frontmatter edits never move them. + hash: + type: string + pattern: ^sha256:[0-9a-f]{64}$ + description: SHA-256 of the UTF-8 body the offsets were measured in. + additionalProperties: false + unit: + const: unicode_code_point + start: + type: integer + minimum: 0 + end: + type: integer + minimum: 0 + additionalProperties: false + required: + - quote + additionalProperties: false + suggestion: + type: object + description: "The suggested edit of an editing comment: replace the target's quote with replacement." + required: + - replacement + properties: + replacement: + type: string + description: The text to put in place of the quote. Empty suggests deleting it. + outcome: + enum: + - accepted + - rejected + description: What happened to the suggestion once its thread was resolved. + additionalProperties: false + status: + enum: + - open + - resolved + default: open + description: Whether the thread is still open. Only meaningful on the first comment of a thread. + resolved_by: + type: string + pattern: \S + description: Link to the mdbase.person record of who resolved the thread. + resolved_at: + type: string + format: date-time + created_by: + type: string + pattern: \S + description: Link to the mdbase.person record of the comment's author. Absent when the author has no person record. + created_at: + type: string + format: date-time + modified_at: + type: string + format: date-time + deleted_at: + type: string + format: date-time + description: When the comment was withdrawn. The record stays so its thread and replies keep their shape; its body should be emptied. + allOf: + - if: + properties: + motivation: + const: editing + required: + - motivation + then: + required: + - target + - suggestion + properties: + target: + type: object + suggestion: + type: object + additionalProperties: true +collection: + links: + document: + target_type: any + validate_exists: false + in_reply_to: + target_type: comment + validate_exists: false + created_by: + target_type: any + validate_exists: false + resolved_by: + target_type: any + validate_exists: false + display: + description_field: document + icon: chat-circle +implements: + - contract: mdbase.comment + version: 1.0.0 + fields: + document: document + in_reply_to: in_reply_to + motivation: motivation + target: target + suggestion: suggestion + status: status + resolved_by: resolved_by + resolved_at: resolved_at + created_by: created_by + created_at: created_at + modified_at: modified_at + deleted_at: deleted_at +--- + +# Comment + +One note per comment, reply or suggested edit. Apps create these for you; the +commented note itself is never changed by commenting on it. + +```yaml +type: comment +document: "[[chapters/method]]" +target: + quote: { exact: "suggests strongly", prefix: "the evidence ", suffix: " that" } +created_by: "[[Alex Rivera]]" +created_at: 2026-09-29T10:00:00Z +``` + +The note's body is the comment's text. A reply is another Comment note whose +`in_reply_to` links to the thread's first comment. A suggested edit has +`motivation: editing` and a `suggestion.replacement`. + +`document`, `in_reply_to`, `created_by` and `resolved_by` are links, so +renaming or moving a note with a tool that updates references keeps them +current. Links to a note that has since been deleted are kept, not treated as +errors. `created_by` and `resolved_by` link to Person notes; they are +editable data, not proof of who wrote a comment. + +This type belongs to your collection once installed. You can add fields, or +rename fields and update the `implements` mapping; apps read comments through +the `mdbase.comment` contract, not through these local names. diff --git a/dist/catalog.json b/dist/catalog.json index 8c6e714..174ca55 100644 --- a/dist/catalog.json +++ b/dist/catalog.json @@ -9,6 +9,16 @@ "url": "https://mdbase.dev/" }, "contracts": [ + { + "id": "mdbase.comment", + "version": "1.0.0", + "name": "Comment", + "description": "Comments, replies and suggested edits anchored to text in Markdown records.", + "contract_type": "record", + "digest": "sha256:c85663fbb339d33e8e67825c81fc160fbac00285a1687cbe99187bad7f58878d", + "artifact": "./artifacts/contracts/mdbase.comment/1.0.0.md", + "standards": [] + }, { "id": "mdbase.contact", "version": "1.0.0", @@ -241,6 +251,44 @@ } ], "packs": [ + { + "id": "mdbase.comment", + "version": "1.0.0", + "name": "Comments type pack", + "description": "Comments, replies and suggested edits anchored to text in Markdown records.", + "digest": "sha256:37729ce8eca79ed52b669b191350056cbd23d2fe1453d38b56805979ce0d9e29", + "provision": "./packs/mdbase.comment/1.0.0.json", + "provides": [ + { + "id": "mdbase.comment", + "version": "1.0.0", + "digest": "sha256:c2c5b3f3013d10625310401e9f9149ffa2d09c5b7d91b4fc97688d3ac010bec5" + } + ], + "resource_count": 3, + "display": { + "name": "Comments", + "summary": "Discuss and suggest edits to notes without changing their text, in any app that reads comments.", + "category": "work", + "audience": "general", + "icon": "chat-circle", + "badges": [ + "Portable comments", + "Suggested edits" + ] + }, + "installation": { + "visibility": "default", + "recommendation": "optional", + "primary_type": "comment", + "types": [ + { + "name": "comment", + "label": "Comment" + } + ] + } + }, { "id": "mdbase.contact", "version": "1.0.0", diff --git a/dist/packs/mdbase.comment/1.0.0.json b/dist/packs/mdbase.comment/1.0.0.json new file mode 100644 index 0000000..35a22ea --- /dev/null +++ b/dist/packs/mdbase.comment/1.0.0.json @@ -0,0 +1,53 @@ +{ + "manifest": { + "kind": "mdbase.type-pack", + "id": "mdbase.comment", + "version": "1.0.0", + "name": "Comments type pack", + "description": "Comments, replies and suggested edits anchored to text in Markdown records.", + "resources": [ + { + "kind": "schema", + "mode": "managed", + "source": "schemas/mdbase.comment/1.0.0.schema.json", + "target": "schemas/mdbase.comment/1.0.0.schema.json", + "digest": "sha256:b845720a44e3fcea2385ef33ae2803531f2fad655efdc2c262e51f92f24f0e74" + }, + { + "kind": "contract", + "mode": "managed", + "source": "contracts/mdbase.comment/1.0.0.md", + "target": "_contracts/mdbase.comment/1.0.0.md", + "digest": "sha256:c85663fbb339d33e8e67825c81fc160fbac00285a1687cbe99187bad7f58878d" + }, + { + "kind": "type", + "mode": "seed", + "source": "types/comment/1.md", + "target": "_types/comment.md", + "digest": "sha256:e8abf287b9d3df0035fd32a2a584d7a8d21eddd7b4a3201fd32a3babe6ac9d9e" + } + ] + }, + "resources": [ + { + "source": "schemas/mdbase.comment/1.0.0.schema.json", + "document": "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"$id\": \"https://mdbase.dev/contracts/schemas/mdbase.comment/1.0.0.schema.json\",\n \"title\": \"Comment\",\n \"description\": \"A comment, reply or suggested edit on a Markdown record, stored as its own record. The comment's Markdown body is its text.\",\n \"type\": \"object\",\n \"required\": [\n \"document\",\n \"created_at\"\n ],\n \"properties\": {\n \"document\": {\n \"type\": \"string\",\n \"pattern\": \"\\\\S\",\n \"description\": \"Link to the commented record, such as [[chapters/method]]. Replies repeat their thread's document so one query finds a record's whole discussion.\"\n },\n \"in_reply_to\": {\n \"type\": \"string\",\n \"pattern\": \"\\\\S\",\n \"description\": \"Link to the first comment of the thread this comment replies to. Absent on the first comment of a thread.\"\n },\n \"motivation\": {\n \"enum\": [\n \"commenting\",\n \"replying\",\n \"editing\"\n ],\n \"default\": \"commenting\",\n \"description\": \"commenting starts a thread, replying answers one, and editing suggests replacing the target's text.\"\n },\n \"target\": {\n \"type\": \"object\",\n \"description\": \"Where in the document the thread is anchored. Absent means the whole record. Only the first comment of a thread has a target.\",\n \"properties\": {\n \"quote\": {\n \"type\": \"object\",\n \"description\": \"The anchored text and the text around it. The quote is authoritative; positions are a hint.\",\n \"required\": [\n \"exact\"\n ],\n \"properties\": {\n \"exact\": {\n \"type\": \"string\",\n \"description\": \"The anchored text exactly as it appears in the record body. Empty for an insertion point, which prefix or suffix then locates.\"\n },\n \"prefix\": {\n \"type\": \"string\",\n \"description\": \"Text immediately before exact, to tell repeated occurrences apart.\"\n },\n \"suffix\": {\n \"type\": \"string\",\n \"description\": \"Text immediately after exact, to tell repeated occurrences apart.\"\n }\n },\n \"if\": {\n \"required\": [\n \"exact\"\n ],\n \"properties\": {\n \"exact\": {\n \"type\": \"string\",\n \"maxLength\": 0\n }\n }\n },\n \"then\": {\n \"anyOf\": [\n {\n \"required\": [\n \"prefix\"\n ],\n \"properties\": {\n \"prefix\": {\n \"type\": \"string\",\n \"minLength\": 1\n }\n }\n },\n {\n \"required\": [\n \"suffix\"\n ],\n \"properties\": {\n \"suffix\": {\n \"type\": \"string\",\n \"minLength\": 1\n }\n }\n }\n ]\n },\n \"additionalProperties\": false\n },\n \"text_position\": {\n \"type\": \"object\",\n \"description\": \"Offsets of the quote in one revision of the record body.\",\n \"required\": [\n \"basis\",\n \"unit\",\n \"start\",\n \"end\"\n ],\n \"properties\": {\n \"basis\": {\n \"type\": \"object\",\n \"required\": [\n \"profile\",\n \"hash\"\n ],\n \"properties\": {\n \"profile\": {\n \"const\": \"markdown-body\",\n \"description\": \"Offsets count the record's Markdown body as stored, after the frontmatter, so frontmatter edits never move them.\"\n },\n \"hash\": {\n \"type\": \"string\",\n \"pattern\": \"^sha256:[0-9a-f]{64}$\",\n \"description\": \"SHA-256 of the UTF-8 body the offsets were measured in.\"\n }\n },\n \"additionalProperties\": false\n },\n \"unit\": {\n \"const\": \"unicode_code_point\"\n },\n \"start\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"end\": {\n \"type\": \"integer\",\n \"minimum\": 0\n }\n },\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"quote\"\n ],\n \"additionalProperties\": false\n },\n \"suggestion\": {\n \"type\": \"object\",\n \"description\": \"The suggested edit of an editing comment: replace the target's quote with replacement.\",\n \"required\": [\n \"replacement\"\n ],\n \"properties\": {\n \"replacement\": {\n \"type\": \"string\",\n \"description\": \"The text to put in place of the quote. Empty suggests deleting it.\"\n },\n \"outcome\": {\n \"enum\": [\n \"accepted\",\n \"rejected\"\n ],\n \"description\": \"What happened to the suggestion once its thread was resolved.\"\n }\n },\n \"additionalProperties\": false\n },\n \"status\": {\n \"enum\": [\n \"open\",\n \"resolved\"\n ],\n \"default\": \"open\",\n \"description\": \"Whether the thread is still open. Only meaningful on the first comment of a thread.\"\n },\n \"resolved_by\": {\n \"type\": \"string\",\n \"pattern\": \"\\\\S\",\n \"description\": \"Link to the mdbase.person record of who resolved the thread.\"\n },\n \"resolved_at\": {\n \"type\": \"string\",\n \"format\": \"date-time\"\n },\n \"created_by\": {\n \"type\": \"string\",\n \"pattern\": \"\\\\S\",\n \"description\": \"Link to the mdbase.person record of the comment's author. Absent when the author has no person record.\"\n },\n \"created_at\": {\n \"type\": \"string\",\n \"format\": \"date-time\"\n },\n \"modified_at\": {\n \"type\": \"string\",\n \"format\": \"date-time\"\n },\n \"deleted_at\": {\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"description\": \"When the comment was withdrawn. The record stays so its thread and replies keep their shape; its body should be emptied.\"\n }\n },\n \"allOf\": [\n {\n \"if\": {\n \"properties\": {\n \"motivation\": {\n \"const\": \"editing\"\n }\n },\n \"required\": [\n \"motivation\"\n ]\n },\n \"then\": {\n \"required\": [\n \"target\",\n \"suggestion\"\n ],\n \"properties\": {\n \"target\": {\n \"type\": \"object\"\n },\n \"suggestion\": {\n \"type\": \"object\"\n }\n }\n }\n }\n ],\n \"additionalProperties\": false\n}\n" + }, + { + "source": "contracts/mdbase.comment/1.0.0.md", + "document": "---\nkind: mdbase.contract\ncontract_type: record\nid: mdbase.comment\nversion: 1.0.0\nname: Comment\ndescription: Comments, replies and suggested edits anchored to text in Markdown records.\nrecord_schema:\n dialect: json-schema-2020-12\n ref: ../../schemas/mdbase.comment/1.0.0.schema.json\n---\n\n# Comment\n\nA comment is its own record, never markup inside the commented record. The\ncommented record's Markdown stays exactly as its author wrote it, and comments\ncan be queried, linked to and validated like any other record. The comment's\nMarkdown body is its text; a link to a person in that text is a mention.\n\n## Threads\n\nThe first comment of a thread has no `in_reply_to`. It carries the thread's\nanchor (`target`) and state (`status`, `resolved_by`, `resolved_at`). Each reply\nis a separate record whose `in_reply_to` links to that first comment, so two\npeople replying at once write two records and never conflict. Replies repeat\nthe thread's `document`. Order a thread's replies by `created_at`.\n\nRefer to records only with ordinary mdbase links: `document` to the commented\nrecord, `in_reply_to` to the first comment, `created_by` and `resolved_by` to\nrecords implementing `mdbase.person`. Implementing types declare these fields in\n`collection.links` so rename reference updates keep them current. Never refer\nto an author by name, email or account subject.\n\n## Anchors\n\n`target.quote` is the source of truth: `exact` is the anchored text and\n`prefix` and `suffix` are the text around it. `target.text_position` records\nwhere the quote was in one revision of the body, in Unicode code points of the\nrecord's Markdown body as stored, after its frontmatter (profile\n`markdown-body`). `basis.hash` is `sha256:` and the hex SHA-256 of that body's\nUTF-8 bytes.\n\nTo find a thread's text:\n\n1. When the body at the recorded offsets is still `exact`, use them.\n2. Otherwise search the body for `exact`, preferring the occurrence whose\n surrounding text best matches `prefix` and `suffix`, then the one nearest\n the recorded offsets.\n3. When `exact` is not found, the thread is detached. Show it apart from the\n text rather than guessing a place. A detached thread is not an error.\n\nAn empty `exact` is an insertion point between `prefix` and `suffix`. A thread\nwithout a `target` is about the whole record. A consumer may rewrite\n`text_position` after finding the quote elsewhere; it must not change `quote`.\n\n## Suggested edits\n\nA comment with `motivation: editing` suggests replacing the target's quote with\n`suggestion.replacement`; an empty replacement suggests deleting it. Accepting\na suggestion first finds the quote as above and must refuse, leaving the\nthread open, when the quote cannot be found exactly. It then replaces the\nquote in the commented record and resolves the thread with\n`suggestion.outcome: accepted`. Rejecting resolves it with `rejected`.\n\n## Lifecycle\n\nA comment whose `document` no longer resolves is kept, not deleted: records\ncan be restored, and the discussion is still collection data. Consumers may\nhide such comments. Withdrawing a comment sets `deleted_at` and empties its\nbody rather than deleting the record, so replies to it keep their thread.\n\nAuthorship links are editable collection data with the same trust as any other\nfield. They never prove who wrote a comment or grant anyone access.\n" + }, + { + "source": "types/comment/1.md", + "document": "---\nkind: mdbase.type\nname: comment\nversion: 1\ndescription: A comment, reply or suggested edit on another note, anchored to a passage of its text.\nmatch:\n where:\n type: comment\nschema:\n dialect: json-schema-2020-12\n value:\n $schema: https://json-schema.org/draft/2020-12/schema\n title: Comment\n description: A comment, reply or suggested edit on a Markdown record, stored as its own record. The comment's Markdown body is its text.\n type: object\n required:\n - type\n - document\n - created_at\n properties:\n type:\n const: comment\n description: Identifies this note as a Comment. Set by the application that creates it.\n document:\n type: string\n pattern: \\S\n description: Link to the commented record, such as [[chapters/method]]. Replies repeat their thread's document so one query finds a record's whole discussion.\n in_reply_to:\n type: string\n pattern: \\S\n description: Link to the first comment of the thread this comment replies to. Absent on the first comment of a thread.\n motivation:\n enum:\n - commenting\n - replying\n - editing\n default: commenting\n description: commenting starts a thread, replying answers one, and editing suggests replacing the target's text.\n target:\n type: object\n description: Where in the document the thread is anchored. Absent means the whole record. Only the first comment of a thread has a target.\n properties:\n quote:\n type: object\n description: The anchored text and the text around it. The quote is authoritative; positions are a hint.\n required:\n - exact\n properties:\n exact:\n type: string\n description: The anchored text exactly as it appears in the record body. Empty for an insertion point, which prefix or suffix then locates.\n prefix:\n type: string\n description: Text immediately before exact, to tell repeated occurrences apart.\n suffix:\n type: string\n description: Text immediately after exact, to tell repeated occurrences apart.\n if:\n required:\n - exact\n properties:\n exact:\n type: string\n maxLength: 0\n then:\n anyOf:\n - required:\n - prefix\n properties:\n prefix:\n type: string\n minLength: 1\n - required:\n - suffix\n properties:\n suffix:\n type: string\n minLength: 1\n additionalProperties: false\n text_position:\n type: object\n description: Offsets of the quote in one revision of the record body.\n required:\n - basis\n - unit\n - start\n - end\n properties:\n basis:\n type: object\n required:\n - profile\n - hash\n properties:\n profile:\n const: markdown-body\n description: Offsets count the record's Markdown body as stored, after the frontmatter, so frontmatter edits never move them.\n hash:\n type: string\n pattern: ^sha256:[0-9a-f]{64}$\n description: SHA-256 of the UTF-8 body the offsets were measured in.\n additionalProperties: false\n unit:\n const: unicode_code_point\n start:\n type: integer\n minimum: 0\n end:\n type: integer\n minimum: 0\n additionalProperties: false\n required:\n - quote\n additionalProperties: false\n suggestion:\n type: object\n description: \"The suggested edit of an editing comment: replace the target's quote with replacement.\"\n required:\n - replacement\n properties:\n replacement:\n type: string\n description: The text to put in place of the quote. Empty suggests deleting it.\n outcome:\n enum:\n - accepted\n - rejected\n description: What happened to the suggestion once its thread was resolved.\n additionalProperties: false\n status:\n enum:\n - open\n - resolved\n default: open\n description: Whether the thread is still open. Only meaningful on the first comment of a thread.\n resolved_by:\n type: string\n pattern: \\S\n description: Link to the mdbase.person record of who resolved the thread.\n resolved_at:\n type: string\n format: date-time\n created_by:\n type: string\n pattern: \\S\n description: Link to the mdbase.person record of the comment's author. Absent when the author has no person record.\n created_at:\n type: string\n format: date-time\n modified_at:\n type: string\n format: date-time\n deleted_at:\n type: string\n format: date-time\n description: When the comment was withdrawn. The record stays so its thread and replies keep their shape; its body should be emptied.\n allOf:\n - if:\n properties:\n motivation:\n const: editing\n required:\n - motivation\n then:\n required:\n - target\n - suggestion\n properties:\n target:\n type: object\n suggestion:\n type: object\n additionalProperties: true\ncollection:\n links:\n document:\n target_type: any\n validate_exists: false\n in_reply_to:\n target_type: comment\n validate_exists: false\n created_by:\n target_type: any\n validate_exists: false\n resolved_by:\n target_type: any\n validate_exists: false\n display:\n description_field: document\n icon: chat-circle\nimplements:\n - contract: mdbase.comment\n version: 1.0.0\n fields:\n document: document\n in_reply_to: in_reply_to\n motivation: motivation\n target: target\n suggestion: suggestion\n status: status\n resolved_by: resolved_by\n resolved_at: resolved_at\n created_by: created_by\n created_at: created_at\n modified_at: modified_at\n deleted_at: deleted_at\n---\n\n# Comment\n\nOne note per comment, reply or suggested edit. Apps create these for you; the\ncommented note itself is never changed by commenting on it.\n\n```yaml\ntype: comment\ndocument: \"[[chapters/method]]\"\ntarget:\n quote: { exact: \"suggests strongly\", prefix: \"the evidence \", suffix: \" that\" }\ncreated_by: \"[[Alex Rivera]]\"\ncreated_at: 2026-09-29T10:00:00Z\n```\n\nThe note's body is the comment's text. A reply is another Comment note whose\n`in_reply_to` links to the thread's first comment. A suggested edit has\n`motivation: editing` and a `suggestion.replacement`.\n\n`document`, `in_reply_to`, `created_by` and `resolved_by` are links, so\nrenaming or moving a note with a tool that updates references keeps them\ncurrent. Links to a note that has since been deleted are kept, not treated as\nerrors. `created_by` and `resolved_by` link to Person notes; they are\neditable data, not proof of who wrote a comment.\n\nThis type belongs to your collection once installed. You can add fields, or\nrename fields and update the `implements` mapping; apps read comments through\nthe `mdbase.comment` contract, not through these local names.\n" + } + ], + "provides": [ + { + "id": "mdbase.comment", + "version": "1.0.0", + "digest": "sha256:c2c5b3f3013d10625310401e9f9149ffa2d09c5b7d91b4fc97688d3ac010bec5" + } + ] +} diff --git a/dist/schemas/mdbase.comment/1.0.0.schema.json b/dist/schemas/mdbase.comment/1.0.0.schema.json new file mode 100644 index 0000000..ae810d6 --- /dev/null +++ b/dist/schemas/mdbase.comment/1.0.0.schema.json @@ -0,0 +1,229 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/contracts/schemas/mdbase.comment/1.0.0.schema.json", + "title": "Comment", + "description": "A comment, reply or suggested edit on a Markdown record, stored as its own record. The comment's Markdown body is its text.", + "type": "object", + "required": [ + "document", + "created_at" + ], + "properties": { + "document": { + "type": "string", + "pattern": "\\S", + "description": "Link to the commented record, such as [[chapters/method]]. Replies repeat their thread's document so one query finds a record's whole discussion." + }, + "in_reply_to": { + "type": "string", + "pattern": "\\S", + "description": "Link to the first comment of the thread this comment replies to. Absent on the first comment of a thread." + }, + "motivation": { + "enum": [ + "commenting", + "replying", + "editing" + ], + "default": "commenting", + "description": "commenting starts a thread, replying answers one, and editing suggests replacing the target's text." + }, + "target": { + "type": "object", + "description": "Where in the document the thread is anchored. Absent means the whole record. Only the first comment of a thread has a target.", + "properties": { + "quote": { + "type": "object", + "description": "The anchored text and the text around it. The quote is authoritative; positions are a hint.", + "required": [ + "exact" + ], + "properties": { + "exact": { + "type": "string", + "description": "The anchored text exactly as it appears in the record body. Empty for an insertion point, which prefix or suffix then locates." + }, + "prefix": { + "type": "string", + "description": "Text immediately before exact, to tell repeated occurrences apart." + }, + "suffix": { + "type": "string", + "description": "Text immediately after exact, to tell repeated occurrences apart." + } + }, + "if": { + "required": [ + "exact" + ], + "properties": { + "exact": { + "type": "string", + "maxLength": 0 + } + } + }, + "then": { + "anyOf": [ + { + "required": [ + "prefix" + ], + "properties": { + "prefix": { + "type": "string", + "minLength": 1 + } + } + }, + { + "required": [ + "suffix" + ], + "properties": { + "suffix": { + "type": "string", + "minLength": 1 + } + } + } + ] + }, + "additionalProperties": false + }, + "text_position": { + "type": "object", + "description": "Offsets of the quote in one revision of the record body.", + "required": [ + "basis", + "unit", + "start", + "end" + ], + "properties": { + "basis": { + "type": "object", + "required": [ + "profile", + "hash" + ], + "properties": { + "profile": { + "const": "markdown-body", + "description": "Offsets count the record's Markdown body as stored, after the frontmatter, so frontmatter edits never move them." + }, + "hash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "SHA-256 of the UTF-8 body the offsets were measured in." + } + }, + "additionalProperties": false + }, + "unit": { + "const": "unicode_code_point" + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + } + }, + "required": [ + "quote" + ], + "additionalProperties": false + }, + "suggestion": { + "type": "object", + "description": "The suggested edit of an editing comment: replace the target's quote with replacement.", + "required": [ + "replacement" + ], + "properties": { + "replacement": { + "type": "string", + "description": "The text to put in place of the quote. Empty suggests deleting it." + }, + "outcome": { + "enum": [ + "accepted", + "rejected" + ], + "description": "What happened to the suggestion once its thread was resolved." + } + }, + "additionalProperties": false + }, + "status": { + "enum": [ + "open", + "resolved" + ], + "default": "open", + "description": "Whether the thread is still open. Only meaningful on the first comment of a thread." + }, + "resolved_by": { + "type": "string", + "pattern": "\\S", + "description": "Link to the mdbase.person record of who resolved the thread." + }, + "resolved_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "pattern": "\\S", + "description": "Link to the mdbase.person record of the comment's author. Absent when the author has no person record." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "modified_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": "string", + "format": "date-time", + "description": "When the comment was withdrawn. The record stays so its thread and replies keep their shape; its body should be emptied." + } + }, + "allOf": [ + { + "if": { + "properties": { + "motivation": { + "const": "editing" + } + }, + "required": [ + "motivation" + ] + }, + "then": { + "required": [ + "target", + "suggestion" + ], + "properties": { + "target": { + "type": "object" + }, + "suggestion": { + "type": "object" + } + } + } + } + ], + "additionalProperties": false +} diff --git a/package.json b/package.json index 7ad07c3..fa2c410 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "node scripts/build.mjs", "expand:type": "node scripts/expand-type.mjs", "verify": "node scripts/verify.mjs", - "test": "node --test scripts/schema-expansion.test.mjs scripts/person-contract.test.mjs && npm run build && npm run verify && node --test scripts/person-pack.test.mjs scripts/tasknotes-upgrade.test.mjs scripts/tasknotes-plugin-collections.test.mjs" + "test": "node --test scripts/schema-expansion.test.mjs scripts/person-contract.test.mjs scripts/comment-contract.test.mjs && npm run build && npm run verify && node --test scripts/person-pack.test.mjs scripts/tasknotes-upgrade.test.mjs scripts/tasknotes-plugin-collections.test.mjs" }, "dependencies": { "ajv": "^8.20.0", diff --git a/packs/mdbase.comment/1.0.0.pack.yaml b/packs/mdbase.comment/1.0.0.pack.yaml new file mode 100644 index 0000000..0e8a3a9 --- /dev/null +++ b/packs/mdbase.comment/1.0.0.pack.yaml @@ -0,0 +1,34 @@ +kind: mdbase.catalog-pack +id: mdbase.comment +version: 1.0.0 +name: Comments type pack +description: Comments, replies and suggested edits anchored to text in Markdown records. +display: + name: Comments + summary: Discuss and suggest edits to notes without changing their text, in any app that reads comments. + category: work + audience: general + icon: chat-circle + badges: + - Portable comments + - Suggested edits +installation: + visibility: default + recommendation: optional + primary_type: comment +provides: + - id: mdbase.comment + version: 1.0.0 +resources: + - kind: schema + mode: managed + source: schemas/mdbase.comment/1.0.0.schema.json + target: schemas/mdbase.comment/1.0.0.schema.json + - kind: contract + mode: managed + source: contracts/mdbase.comment/1.0.0.md + target: _contracts/mdbase.comment/1.0.0.md + - kind: type + mode: seed + source: types/comment/1.md + target: _types/comment.md diff --git a/schemas/mdbase.comment/1.0.0.schema.json b/schemas/mdbase.comment/1.0.0.schema.json new file mode 100644 index 0000000..ae810d6 --- /dev/null +++ b/schemas/mdbase.comment/1.0.0.schema.json @@ -0,0 +1,229 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mdbase.dev/contracts/schemas/mdbase.comment/1.0.0.schema.json", + "title": "Comment", + "description": "A comment, reply or suggested edit on a Markdown record, stored as its own record. The comment's Markdown body is its text.", + "type": "object", + "required": [ + "document", + "created_at" + ], + "properties": { + "document": { + "type": "string", + "pattern": "\\S", + "description": "Link to the commented record, such as [[chapters/method]]. Replies repeat their thread's document so one query finds a record's whole discussion." + }, + "in_reply_to": { + "type": "string", + "pattern": "\\S", + "description": "Link to the first comment of the thread this comment replies to. Absent on the first comment of a thread." + }, + "motivation": { + "enum": [ + "commenting", + "replying", + "editing" + ], + "default": "commenting", + "description": "commenting starts a thread, replying answers one, and editing suggests replacing the target's text." + }, + "target": { + "type": "object", + "description": "Where in the document the thread is anchored. Absent means the whole record. Only the first comment of a thread has a target.", + "properties": { + "quote": { + "type": "object", + "description": "The anchored text and the text around it. The quote is authoritative; positions are a hint.", + "required": [ + "exact" + ], + "properties": { + "exact": { + "type": "string", + "description": "The anchored text exactly as it appears in the record body. Empty for an insertion point, which prefix or suffix then locates." + }, + "prefix": { + "type": "string", + "description": "Text immediately before exact, to tell repeated occurrences apart." + }, + "suffix": { + "type": "string", + "description": "Text immediately after exact, to tell repeated occurrences apart." + } + }, + "if": { + "required": [ + "exact" + ], + "properties": { + "exact": { + "type": "string", + "maxLength": 0 + } + } + }, + "then": { + "anyOf": [ + { + "required": [ + "prefix" + ], + "properties": { + "prefix": { + "type": "string", + "minLength": 1 + } + } + }, + { + "required": [ + "suffix" + ], + "properties": { + "suffix": { + "type": "string", + "minLength": 1 + } + } + } + ] + }, + "additionalProperties": false + }, + "text_position": { + "type": "object", + "description": "Offsets of the quote in one revision of the record body.", + "required": [ + "basis", + "unit", + "start", + "end" + ], + "properties": { + "basis": { + "type": "object", + "required": [ + "profile", + "hash" + ], + "properties": { + "profile": { + "const": "markdown-body", + "description": "Offsets count the record's Markdown body as stored, after the frontmatter, so frontmatter edits never move them." + }, + "hash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "SHA-256 of the UTF-8 body the offsets were measured in." + } + }, + "additionalProperties": false + }, + "unit": { + "const": "unicode_code_point" + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + } + }, + "required": [ + "quote" + ], + "additionalProperties": false + }, + "suggestion": { + "type": "object", + "description": "The suggested edit of an editing comment: replace the target's quote with replacement.", + "required": [ + "replacement" + ], + "properties": { + "replacement": { + "type": "string", + "description": "The text to put in place of the quote. Empty suggests deleting it." + }, + "outcome": { + "enum": [ + "accepted", + "rejected" + ], + "description": "What happened to the suggestion once its thread was resolved." + } + }, + "additionalProperties": false + }, + "status": { + "enum": [ + "open", + "resolved" + ], + "default": "open", + "description": "Whether the thread is still open. Only meaningful on the first comment of a thread." + }, + "resolved_by": { + "type": "string", + "pattern": "\\S", + "description": "Link to the mdbase.person record of who resolved the thread." + }, + "resolved_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "pattern": "\\S", + "description": "Link to the mdbase.person record of the comment's author. Absent when the author has no person record." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "modified_at": { + "type": "string", + "format": "date-time" + }, + "deleted_at": { + "type": "string", + "format": "date-time", + "description": "When the comment was withdrawn. The record stays so its thread and replies keep their shape; its body should be emptied." + } + }, + "allOf": [ + { + "if": { + "properties": { + "motivation": { + "const": "editing" + } + }, + "required": [ + "motivation" + ] + }, + "then": { + "required": [ + "target", + "suggestion" + ], + "properties": { + "target": { + "type": "object" + }, + "suggestion": { + "type": "object" + } + } + } + } + ], + "additionalProperties": false +} diff --git a/scripts/comment-contract.test.mjs b/scripts/comment-contract.test.mjs new file mode 100644 index 0000000..a0ed7a7 --- /dev/null +++ b/scripts/comment-contract.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import matter from "gray-matter"; + +const root = new URL("../", import.meta.url); +const commentSchema = JSON.parse(await readFile(new URL( + "schemas/mdbase.comment/1.0.0.schema.json", root, +), "utf8")); +const starter = matter(await readFile(new URL("types/comment/1.md", root), "utf8")).data; +const ajv = new Ajv2020({ allErrors: true, strict: true }); +addFormats(ajv); +const validateComment = ajv.compile(commentSchema); +const validateStarter = ajv.compile(starter.schema.value); + +const hash = `sha256:${"a".repeat(64)}`; +const thread = { + document: "[[chapters/method]]", + motivation: "commenting", + target: { + quote: { exact: "suggests strongly", prefix: "the evidence ", suffix: " that" }, + text_position: { basis: { profile: "markdown-body", hash }, unit: "unicode_code_point", start: 120, end: 137 }, + }, + status: "open", + created_by: "[[Alex Rivera]]", + created_at: "2026-09-29T10:00:00Z", +}; + +function valid(validate, value) { + assert.equal(validate(value), true, ajv.errorsText(validate.errors)); +} + +function invalid(validate, value) { + assert.equal(validate(value), false, `accepted ${JSON.stringify(value)}`); +} + +test("a thread, a reply and a whole-record comment", () => { + valid(validateComment, thread); + valid(validateComment, { + document: thread.document, + in_reply_to: "[[comments/01k6-thread]]", + motivation: "replying", + created_at: "2026-09-29T10:05:00Z", + }); + valid(validateComment, { document: thread.document, created_at: thread.created_at }); +}); + +test("an anonymous comment needs no person link", () => { + const { created_by: _, ...anonymous } = thread; + valid(validateComment, anonymous); +}); + +test("a suggestion needs a target and a replacement", () => { + valid(validateComment, { ...thread, motivation: "editing", suggestion: { replacement: "suggests" } }); + valid(validateComment, { ...thread, motivation: "editing", suggestion: { replacement: "" } }); + valid(validateComment, { ...thread, motivation: "editing", status: "resolved", suggestion: { replacement: "x", outcome: "accepted" } }); + invalid(validateComment, { ...thread, motivation: "editing" }); + const { target: _, ...untargeted } = thread; + invalid(validateComment, { ...untargeted, motivation: "editing", suggestion: { replacement: "x" } }); +}); + +test("an insertion point is located by the text around it", () => { + const insertion = (quote) => ({ ...thread, motivation: "editing", target: { quote }, suggestion: { replacement: ", clearly," } }); + valid(validateComment, insertion({ exact: "", prefix: "The evidence" })); + valid(validateComment, insertion({ exact: "", suffix: " suggests" })); + invalid(validateComment, insertion({ exact: "" })); + invalid(validateComment, insertion({ exact: "", prefix: "" })); +}); + +test("positions are code points of the Markdown body", () => { + const at = (text_position) => ({ ...thread, target: { quote: thread.target.quote, text_position } }); + invalid(validateComment, at({ ...thread.target.text_position, unit: "utf16_code_unit" })); + invalid(validateComment, at({ ...thread.target.text_position, basis: { profile: "html", hash } })); + invalid(validateComment, at({ ...thread.target.text_position, basis: { profile: "markdown-body", hash: "abc" } })); + invalid(validateComment, at({ ...thread.target.text_position, start: -1 })); +}); + +test("a target always quotes its text", () => { + invalid(validateComment, { ...thread, target: { text_position: thread.target.text_position } }); +}); + +test("the contract view is closed; the starter type is open", () => { + invalid(validateComment, { ...thread, colour: "yellow" }); + valid(validateStarter, { type: "comment", ...thread, colour: "yellow" }); + invalid(validateStarter, thread); +}); + +test("the starter maps every contract field to itself and links every reference", () => { + const mapping = starter.implements.find(({ contract }) => contract === "mdbase.comment"); + assert.equal(mapping.version, "1.0.0"); + assert.deepEqual(Object.keys(mapping.fields).sort(), Object.keys(commentSchema.properties).sort()); + for (const [canonical, local] of Object.entries(mapping.fields)) assert.equal(canonical, local); + const links = starter.collection.links; + for (const field of ["document", "in_reply_to", "created_by", "resolved_by"]) { + assert.equal(links[field]?.validate_exists, false, `${field} must tolerate a deleted target`); + } +}); + +test("the starter's schema is the contract's, plus the type key", () => { + const { type, ...properties } = starter.schema.value.properties; + assert.deepEqual(type, { const: "comment", description: type.description }); + assert.deepEqual(properties, commentSchema.properties); + assert.deepEqual(starter.schema.value.allOf, commentSchema.allOf); +}); diff --git a/types/comment/1.md b/types/comment/1.md new file mode 100644 index 0000000..fc439af --- /dev/null +++ b/types/comment/1.md @@ -0,0 +1,235 @@ +--- +kind: mdbase.type +name: comment +version: 1 +description: A comment, reply or suggested edit on another note, anchored to a passage of its text. +match: + where: + type: comment +schema: + dialect: json-schema-2020-12 + value: + $schema: https://json-schema.org/draft/2020-12/schema + title: Comment + description: A comment, reply or suggested edit on a Markdown record, stored as its own record. The comment's Markdown body is its text. + type: object + required: + - type + - document + - created_at + properties: + type: + const: comment + description: Identifies this note as a Comment. Set by the application that creates it. + document: + type: string + pattern: \S + description: Link to the commented record, such as [[chapters/method]]. Replies repeat their thread's document so one query finds a record's whole discussion. + in_reply_to: + type: string + pattern: \S + description: Link to the first comment of the thread this comment replies to. Absent on the first comment of a thread. + motivation: + enum: + - commenting + - replying + - editing + default: commenting + description: commenting starts a thread, replying answers one, and editing suggests replacing the target's text. + target: + type: object + description: Where in the document the thread is anchored. Absent means the whole record. Only the first comment of a thread has a target. + properties: + quote: + type: object + description: The anchored text and the text around it. The quote is authoritative; positions are a hint. + required: + - exact + properties: + exact: + type: string + description: The anchored text exactly as it appears in the record body. Empty for an insertion point, which prefix or suffix then locates. + prefix: + type: string + description: Text immediately before exact, to tell repeated occurrences apart. + suffix: + type: string + description: Text immediately after exact, to tell repeated occurrences apart. + if: + required: + - exact + properties: + exact: + type: string + maxLength: 0 + then: + anyOf: + - required: + - prefix + properties: + prefix: + type: string + minLength: 1 + - required: + - suffix + properties: + suffix: + type: string + minLength: 1 + additionalProperties: false + text_position: + type: object + description: Offsets of the quote in one revision of the record body. + required: + - basis + - unit + - start + - end + properties: + basis: + type: object + required: + - profile + - hash + properties: + profile: + const: markdown-body + description: Offsets count the record's Markdown body as stored, after the frontmatter, so frontmatter edits never move them. + hash: + type: string + pattern: ^sha256:[0-9a-f]{64}$ + description: SHA-256 of the UTF-8 body the offsets were measured in. + additionalProperties: false + unit: + const: unicode_code_point + start: + type: integer + minimum: 0 + end: + type: integer + minimum: 0 + additionalProperties: false + required: + - quote + additionalProperties: false + suggestion: + type: object + description: "The suggested edit of an editing comment: replace the target's quote with replacement." + required: + - replacement + properties: + replacement: + type: string + description: The text to put in place of the quote. Empty suggests deleting it. + outcome: + enum: + - accepted + - rejected + description: What happened to the suggestion once its thread was resolved. + additionalProperties: false + status: + enum: + - open + - resolved + default: open + description: Whether the thread is still open. Only meaningful on the first comment of a thread. + resolved_by: + type: string + pattern: \S + description: Link to the mdbase.person record of who resolved the thread. + resolved_at: + type: string + format: date-time + created_by: + type: string + pattern: \S + description: Link to the mdbase.person record of the comment's author. Absent when the author has no person record. + created_at: + type: string + format: date-time + modified_at: + type: string + format: date-time + deleted_at: + type: string + format: date-time + description: When the comment was withdrawn. The record stays so its thread and replies keep their shape; its body should be emptied. + allOf: + - if: + properties: + motivation: + const: editing + required: + - motivation + then: + required: + - target + - suggestion + properties: + target: + type: object + suggestion: + type: object + additionalProperties: true +collection: + links: + document: + target_type: any + validate_exists: false + in_reply_to: + target_type: comment + validate_exists: false + created_by: + target_type: any + validate_exists: false + resolved_by: + target_type: any + validate_exists: false + display: + description_field: document + icon: chat-circle +implements: + - contract: mdbase.comment + version: 1.0.0 + fields: + document: document + in_reply_to: in_reply_to + motivation: motivation + target: target + suggestion: suggestion + status: status + resolved_by: resolved_by + resolved_at: resolved_at + created_by: created_by + created_at: created_at + modified_at: modified_at + deleted_at: deleted_at +--- + +# Comment + +One note per comment, reply or suggested edit. Apps create these for you; the +commented note itself is never changed by commenting on it. + +```yaml +type: comment +document: "[[chapters/method]]" +target: + quote: { exact: "suggests strongly", prefix: "the evidence ", suffix: " that" } +created_by: "[[Alex Rivera]]" +created_at: 2026-09-29T10:00:00Z +``` + +The note's body is the comment's text. A reply is another Comment note whose +`in_reply_to` links to the thread's first comment. A suggested edit has +`motivation: editing` and a `suggestion.replacement`. + +`document`, `in_reply_to`, `created_by` and `resolved_by` are links, so +renaming or moving a note with a tool that updates references keeps them +current. Links to a note that has since been deleted are kept, not treated as +errors. `created_by` and `resolved_by` link to Person notes; they are +editable data, not proof of who wrote a comment. + +This type belongs to your collection once installed. You can add fields, or +rename fields and update the `implements` mapping; apps read comments through +the `mdbase.comment` contract, not through these local names.