diff options
| -rw-r--r-- | doc/edit-proposal.md | 201 | ||||
| -rw-r--r-- | src/schema.edn | 402 |
2 files changed, 603 insertions, 0 deletions
diff --git a/doc/edit-proposal.md b/doc/edit-proposal.md new file mode 100644 index 0000000..806e674 --- /dev/null +++ b/doc/edit-proposal.md @@ -0,0 +1,201 @@ +--- +title: Message editing +layout: spec +work-in-progress: true +copyrights: + - + name: "Andre Hora" + period: "2026" +--- + +## Notes for implementing work-in-progress version + +This is a work-in-progress specification. + +Software implementing this work-in-progress specification MUST NOT use the +unprefixed `message-editing` capability name. +Instead, implementations SHOULD use the `draft/message-editing` capability +name to be interoperable with other software implementing a compatible +work-in-progress version. + +The final version of the specification will use an unprefixed capability name. + +This proposal is adapted from the direction of +[IRCv3 PR #425](https://github.com/ircv3/ircv3-specifications/pull/425) +and mirrors the structure of [`draft/message-redaction`][]. + +## Introduction + +This specification enables messages to be edited after they are sent. +Use cases include correcting typos, updating information, and amending +accidentally sent content. These are cosmetic use cases and do not provide any +operational security guarantees. The original message content remains visible to +clients that do not support this capability, and servers MAY retain the original +content in history. + +## Architecture + +### Dependencies + +Clients wishing to use this capability MUST negotiate the [`message-tags`][] +capability with the server. +Clients SHOULD negotiate the [`echo-message`][] capability in order to receive +message IDs for their own messages, so they can be edited. + +### Capability + +This specification adds the `draft/message-editing` capability. +Clients MUST ignore this capability's value, if any. + +Implementations that negotiate this capability indicate that they are +capable of handling the command described below. + +### Command + +To edit a message, a client MUST negotiate the `draft/message-editing` +capability and send an `EDIT` command to a target nickname or channel. +The command is defined as follows: + + EDIT <target> <msgid> :<new content> + +Where `<msgid>` is the id of the message to be edited, which MUST be a +`PRIVMSG` or `NOTICE`. + +The `<new content>` parameter is required and contains the full replacement text. +As the last parameter, it MAY contain spaces. + +If the client is authorised to edit the message, the server: + +* SHOULD forward this `EDIT`, with an appropriate prefix, to the target + recipients that have negotiated the `draft/message-editing` capability, in the + same way as PRIVMSG messages. +* MUST NOT forward this `EDIT` to target recipients that have not negotiated + this capability (see "Fallback" below). +* SHOULD assign a new `msgid` to the EDIT event itself, distinct from the + original message's `msgid`. + +### Chat history + +After a message is edited, [`chathistory`][] responses SHOULD include the +`EDIT` message after the original message. The original message's content +SHOULD be preserved as-is, with the `EDIT` indicating the replacement. + +Clients reconstructing a conversation from history SHOULD display only the +most recent edit for each original message. + +### Errors + +This specification defines `FAIL` messages using the [standard replies][] +framework for notifying clients of errors with message editing. +The following codes are defined, with sample plain text descriptions. + +* `FAIL EDIT INVALID_TARGET <target> :You cannot edit messages in <target>` +* `FAIL EDIT EDIT_FORBIDDEN <target> <target-msgid> :You are not authorised to edit this message` +* `FAIL EDIT EDIT_WINDOW_EXPIRED <target> <target-msgid> <window> :You can no longer edit this message` +* `FAIL EDIT UNKNOWN_MSGID <target> <target-msgid> :This message does not exist or is too old` + +## Client implementation considerations + +It is strongly RECOMMENDED that clients provide visible edit history to users. +This helps ensure accountability and mitigates abuse through surreptitious +editing. This could be done via a tooltip showing the original text, an +"(edited)" indicator, or a separate log. + +Clients SHOULD display the edited content as the primary message text, with +an indication that the message was modified. + +For the purposes of user interface, clients MAY assume that their own messages +are editable. However, this will not always be the case. Pending a mechanism +for discovering edit permissions, clients SHOULD allow users to attempt to edit +their own messages via some mechanism. + +When an `EDIT` command's `msgid` parameter references a known message not in +the `target`'s history, clients MUST ignore it. + +## Server implementation considerations + +This section is non-normative. + +Servers SHOULD restrict editing to the original message author by default. +Channel operators or server administrators MAY be granted edit permissions +at the server's discretion, though this is less common than for redaction. + +Servers MAY impose a time window after which edits are no longer permitted. +The `EDIT_WINDOW_EXPIRED` error code is provided for this purpose. + +Servers MAY choose to store the full edit history (all versions of a message) +or only the latest version. Datomic-backed implementations naturally retain +full history via immutable datoms. + +### Message validation + +To implement validation, servers require a mechanism for determining whether +a particular edit action is permitted. The same considerations as +[`draft/message-redaction`][] apply: servers can look up message properties +from the ID, or encode required properties within the message ID itself. + +### Fallback + +Server implementations might choose to inform clients that haven't negotiated +the capability that an edit has taken place. The fallback method used (if any) +is left up to server implementations, but could take the form of a standard +NOTICE or PRIVMSG with information about the action. For example: + + :irc.example.com NOTICE #channel :nickname edited a message from 5 seconds ago + +Alternatively, servers could re-send the message as a new PRIVMSG with an +indication that it replaces a prior message, though this risks confusion +with standard clients. + +## Security considerations + +The ability to edit messages does not offer any information or operational +security guarantees. Once a message has been sent, assume that the original +content will remain visible to any recipients or servers, whether or not it +is subsequently edited. Above all else, clients that do not support this +specification will not see any changes to the original message. + +Servers SHOULD retain the original message content in their history backend, +even after an edit, to support audit and accountability use cases. + +## Examples + +Editing a PRIVMSG: + + C: PRIVMSG #channel :an exmple with a tpyo + S: @msgid=123 :nick!u@h PRIVMSG #channel :an exmple with a tpyo + C: EDIT #channel 123 :an example with no typos + S: @msgid=456 :nick!u@h EDIT #channel 123 :an example with no typos + +Editing a message in a DM: + + C: PRIVMSG friend :meet me at 3pm + S: @msgid=789 :nick!u@h PRIVMSG friend :meet me at 3pm + C: EDIT friend 789 :meet me at 4pm actually + S: @msgid=790 :nick!u@h EDIT friend 789 :meet me at 4pm actually + +Failed edit (not authorised): + + C: EDIT #channel 123 :trying to edit someone else's message + S: FAIL EDIT EDIT_FORBIDDEN #channel 123 :You are not authorised to edit this message + +Failed edit (window expired): + + C: EDIT #channel 123 :too late + S: FAIL EDIT EDIT_WINDOW_EXPIRED #channel 123 300 :You can no longer edit this message + +Interaction with chat history: + + C: CHATHISTORY LATEST #channel * 50 + S: BATCH +abc chathistory #channel + S: @batch=abc;msgid=123;time=2026-04-22T10:00:00.000Z :nick!u@h PRIVMSG #channel :an exmple with a tpyo + S: @batch=abc;msgid=456;time=2026-04-22T10:00:05.000Z :nick!u@h EDIT #channel 123 :an example with no typos + S: BATCH -abc + + +[`echo-message`]: ../extensions/echo-message.html +[standard replies]: ../extensions/standard-replies.html +[`message-tags`]: ../extensions/message-tags.html +[`msgid`]: ../extensions/message-ids.html +[`chathistory`]: ../extensions/chathistory.html +[`draft/message-redaction`]: ../extensions/message-redaction.html diff --git a/src/schema.edn b/src/schema.edn new file mode 100644 index 0000000..61cf5fd --- /dev/null +++ b/src/schema.edn @@ -0,0 +1,402 @@ +[#:db{:ident :papod.network/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + {:db/ident :papod.network/name, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/unique :db.unique/value, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.network/description, + :valueType :db.type/string, + :cardinality :db.cardinality/one} + {:db/ident :papod.network/type, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.network/created-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.member/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.member/network, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + {:db/ident :papod.member/nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.member/status, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.member/joined-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.member/network+nick, + :valueType :db.type/tuple, + :cardinality :db.cardinality/one, + :unique :db.unique/value, + :tupleAttrs [:papod.member/network :papod.member/nick]} + #:db{:ident :papod.member-role/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.member-role/member, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + {:db/ident :papod.member-role/role, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.member-role/member+role, + :valueType :db.type/tuple, + :cardinality :db.cardinality/one, + :unique :db.unique/value, + :tupleAttrs [:papod.member-role/member :papod.member-role/role]} + #:db{:ident :papod.channel/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.channel/network, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.channel/name, + :valueType :db.type/string, + :cardinality :db.cardinality/one, + :unique :db.unique/value} + #:db{:ident :papod.channel.type/public} + #:db{:ident :papod.channel.type/private} + #:db{:ident :papod.channel.type/unlisted} + #:db{:ident :papod.channel/type, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.channel/label, + :valueType :db.type/string, + :cardinality :db.cardinality/one} + #:db{:ident :papod.channel/description, + :valueType :db.type/string, + :cardinality :db.cardinality/one} + #:db{:ident :papod.channel/created-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + {:db/ident :papod.channel/owner, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.channel/topic, + :valueType :db.type/string, + :cardinality :db.cardinality/one} + #:db{:ident :papod.channel/last-event-seq, + :valueType :db.type/long, + :cardinality :db.cardinality/one} + #:db{:ident :papod.access/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.access/channel, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + {:db/ident :papod.access/nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.access/level, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.access/channel+nick+level, + :valueType :db.type/tuple, + :cardinality :db.cardinality/one, + :unique :db.unique/value, + :tupleAttrs + [:papod.access/channel :papod.access/nick :papod.access/level]} + #:db{:ident :papod.memo/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + {:db/ident :papod.memo/from, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.memo/to, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.memo/content, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.memo/created-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.memo/read?, + :valueType :db.type/boolean, + :cardinality :db.cardinality/one} + #:db{:ident :papod.membership/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.membership/channel, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + {:db/ident :papod.membership/nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.membership/joined-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.membership/channel+nick, + :valueType :db.type/tuple, + :cardinality :db.cardinality/one, + :unique :db.unique/value, + :tupleAttrs [:papod.membership/channel :papod.membership/nick]} + #:db{:ident :papod.session/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + {:db/ident :papod.session/nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.session/created-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.session/finished-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.connection/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.connection/process, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.connection/created-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.connection/finished-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.logon/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.logon/session, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.logon/connection, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.logon/session+connection, + :valueType :db.type/tuple, + :cardinality :db.cardinality/one, + :unique :db.unique/value, + :tupleAttrs [:papod.logon/session :papod.logon/connection]} + #:db{:ident :papod.process/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.process/pid, + :valueType :db.type/long, + :cardinality :db.cardinality/one} + #:db{:ident :papod.process/hostname, + :valueType :db.type/string, + :cardinality :db.cardinality/one} + #:db{:ident :papod.process/started-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.event/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.event/channel, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + {:db/ident :papod.event/target-nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.event/type, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.event/source-nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.event/payload, + :valueType :db.type/string, + :cardinality :db.cardinality/one} + #:db{:ident :papod.event/created-at, + :valueType :db.type/instant, + :cardinality :db.cardinality/one} + #:db{:ident :papod.event/seq, + :valueType :db.type/long, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.event/reply-to, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.event/edit-of, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.event/delete-of, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + #:db{:ident :papod.reaction/id, + :valueType :db.type/uuid, + :cardinality :db.cardinality/one, + :unique :db.unique/identity} + #:db{:ident :papod.reaction/event, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + {:db/ident :papod.reaction/nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.reaction/emoji, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.reaction/event+nick+emoji, + :valueType :db.type/tuple, + :cardinality :db.cardinality/one, + :unique :db.unique/value, + :tupleAttrs + [:papod.reaction/event + :papod.reaction/nick + :papod.reaction/emoji]} + #:db{:ident :papod.read-marker/channel, + :valueType :db.type/ref, + :cardinality :db.cardinality/one, + :index true} + {:db/ident :papod.read-marker/nick, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db/index true, + :db.attr/preds papod/non-empty?} + {:db/ident :papod.read-marker/timestamp, + :db/valueType :db.type/string, + :db/cardinality :db.cardinality/one, + :db.attr/preds papod/non-empty?} + #:db{:ident :papod.read-marker/channel+nick, + :valueType :db.type/tuple, + :cardinality :db.cardinality/one, + :unique :db.unique/identity, + :tupleAttrs [:papod.read-marker/channel :papod.read-marker/nick]} + {:db/ident :papod.network/attrs, + :db.entity/attrs + #{:papod.network/id + :papod.network/type + :papod.network/created-at + :papod.network/name}} + {:db/ident :papod.member/attrs, + :db.entity/attrs + #{:papod.member/id + :papod.member/network + :papod.member/joined-at + :papod.member/status + :papod.member/nick}} + {:db/ident :papod.member-role/attrs, + :db.entity/attrs + #{:papod.member-role/id + :papod.member-role/role + :papod.member-role/member}} + {:db/ident :papod.channel/attrs, + :db.entity/attrs + #{:papod.channel/type + :papod.channel/id + :papod.channel/network + :papod.channel/created-at}} + {:db/ident :papod.access/attrs, + :db.entity/attrs + #{:papod.access/nick + :papod.access/channel + :papod.access/level + :papod.access/id}} + {:db/ident :papod.memo/attrs, + :db.entity/attrs + #{:papod.memo/id + :papod.memo/created-at + :papod.memo/from + :papod.memo/content + :papod.memo/to + :papod.memo/read?}} + {:db/ident :papod.membership/attrs, + :db.entity/attrs + #{:papod.membership/id + :papod.membership/nick + :papod.membership/joined-at + :papod.membership/channel}} + {:db/ident :papod.session/attrs, + :db.entity/attrs + #{:papod.session/created-at :papod.session/id :papod.session/nick}} + {:db/ident :papod.process/attrs, + :db.entity/attrs + #{:papod.process/started-at + :papod.process/hostname + :papod.process/pid + :papod.process/id}} + {:db/ident :papod.connection/attrs, + :db.entity/attrs + #{:papod.connection/process + :papod.connection/created-at + :papod.connection/id}} + {:db/ident :papod.logon/attrs, + :db.entity/attrs + #{:papod.logon/connection :papod.logon/session :papod.logon/id}} + {:db/ident :papod.event/attrs, + :db.entity/attrs + #{:papod.event/type + :papod.event/source-nick + :papod.event/id + :papod.event/created-at}} + {:db/ident :papod.reaction/attrs, + :db.entity/attrs + #{:papod.reaction/emoji + :papod.reaction/nick + :papod.reaction/id + :papod.reaction/event}} + #:db{:ident :papod.event/add-seq, + :fn + {:lang :clojure, + :imports [], + :requires [], + :params [db event-eid channel-uuid], + :code + "^{:line 38, :column 6} (let [channel-eid ^{:line 38, :column 24} (or ^{:line 38, :column 28} (ffirst ^{:line 39, :column 29} (d/q (quote {:find [?e], :in [$ ?id], :where [[?e :papod.channel/id ?id]]}) db channel-uuid)) ^{:line 43, :column 28} (d/tempid :db.part/user)) last-seq ^{:line 44, :column 24} (or ^{:line 44, :column 28} (:papod.channel/last-event-seq ^{:line 45, :column 29} (d/entity db channel-eid)) 0) new-seq ^{:line 46, :column 24} (inc last-seq)] [[:db/add channel-eid :papod.channel/id channel-uuid] [:db/add channel-eid :papod.channel/last-event-seq new-seq] [:db/add event-eid :papod.event/seq new-seq]])", + :fnref #<Delay@65b5b5ed: :not-delivered>}} + #:db{:ident :papod.access/ensure-unique, + :fn + {:lang :clojure, + :imports [], + :requires [], + :params [db channel nick level], + :code + "^{:line 56, :column 6} (when ^{:line 56, :column 12} (seq ^{:line 57, :column 13} (d/q (quote {:find [?a], :in [$ ?ch ?n ?l], :where [[?a :papod.access/channel ?ch] [?a :papod.access/nick ?n] [?a :papod.access/level ?l]]}) db channel nick level)) ^{:line 63, :column 8} (d/cancel #:cognitect.anomalies{:category :cognitect.anomalies/conflict, :message \"Duplicate access entry\"}))", + :fnref #<Delay@df432ec: :not-delivered>}}] |
