Files
mesh-catalog/modules/mesh-knowledge/cmd/mesh-knowledge/writing.go
T
jschoubben bd9764ef9a CAT-K5: a section is a part with a plain title, never a file; a reference has one form
A new rule, section-form (31 now), refuses from the day the records move a section whose title is a file name, a number
prefix, a slash or over 200 bytes, a title twice in a record, and a text that holds a local path, a file and line, or a
link to a file. Only a section that is new or changed is judged, so a record imported with file-named sections keeps
them until its form-only version; write refuses at once.

Issue 1966.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 11:56:31 +01:00

514 lines
18 KiB
Go

package main
// Writing a record section by section (novox/hq to-be 55 §4, ADR 0592 §17, §19, §20, ADR 0597 §11): a draft is a key of the
// state `drafts` holding its author, the fields of the record and its sections as title, digest and the sequence of the
// write-section that wrote each; `write` puts a section's text into the record's history at once, `submit` makes the
// draft a version through the same rules `propose` is judged by.
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// The bounds of a draft (to-be 55 §4).
const (
MostDraftSections = 64
MostDraftBytes = 1 << 20
// DraftIdle is how long a draft untouched is counted in the health, and DraftDropped when it is dropped.
DraftIdle = 7 * 24 * time.Hour
DraftDropped = 14 * 24 * time.Hour
)
const draftsState = "drafts"
// Draft is a record being written.
type Draft struct {
Kind string `json:"kind"`
Number int `json:"number"`
Author string `json:"author"`
Opened time.Time `json:"opened"`
Touched time.Time `json:"touched"`
// From is the version the draft starts from (0 for a record not yet proposed).
From int `json:"from"`
// Fields are the record's fields and clauses and links as the draft carries them; its sections are Sections.
Fields ProposeAsk `json:"fields"`
Sections []sectionRef `json:"sections"`
// TakenOver is the reason of the last take-over, carried in the next version's message.
TakenOver string `json:"taken-over,omitempty"`
}
// SectionWriter is a store that keeps a section's text the moment it is written: the ledger.
type SectionWriter interface {
// WriteSection appends the text as a write-section of the record and answers its ref.
WriteSection(ctx context.Context, k Key, title, text string) (sectionRef, error)
// SectionText is the text a ref names, held to its digest.
SectionText(ctx context.Context, k Key, ref sectionRef) (string, error)
// Working is the refs of the sections of the version a writer works on (the proposed one, else the served one).
Working(ctx context.Context, k Key) ([]sectionRef, error)
// Known tells the store the sections already written for a draft, so a version that carries them writes none again.
Known(k Key, refs []sectionRef)
}
func draftKey(k Key) string { return fmt.Sprintf("%s.%04d", k.Kind, k.Number) }
// writer is the store's section writer, or the refusal that the records are not yet on the ledger.
func (k *KB) writer() (SectionWriter, error) {
if w, ok := k.Store.(SectionWriter); ok {
return w, nil
}
return nil, errors.New("the records are still kept in the database, which takes a record whole: propose it; writing a draft section by section waits for the move to the ledger")
}
func (k *KB) draftState() (KV, error) {
if k.States == nil {
return nil, errors.New("the knowledge base has no state to keep a draft in")
}
return k.States.State(draftsState), nil
}
// readDraft is the draft of a record and the revision it was read at; nil when there is none.
func (k *KB) readDraft(key Key) (*Draft, uint64, error) {
kv, err := k.draftState()
if err != nil {
return nil, 0, err
}
e, err := kv.Get(draftKey(key))
if err != nil || e == nil {
return nil, 0, err
}
var d Draft
if err := json.Unmarshal(e.Value, &d); err != nil {
return nil, 0, fmt.Errorf("the draft of %s is not a draft: %w", key, err)
}
return &d, e.Revision, nil
}
// keepDraft writes the draft at the revision read, retried at most three times on a lost race and then refused with the
// reason (ADR 0597 §12).
func (k *KB) keepDraft(d *Draft, revision uint64) error {
kv, err := k.draftState()
if err != nil {
return err
}
key := draftKey(Key{d.Kind, d.Number})
for try := 0; try < 3; try++ {
if revision == 0 {
_, err = kv.Create(key, d)
} else {
_, err = kv.Update(key, d, revision)
}
if !errors.Is(err, stdio.ErrChanged) {
return err
}
cur, rev, rerr := k.readDraft(Key{d.Kind, d.Number})
if rerr != nil {
return rerr
}
if cur != nil && cur.Author != d.Author {
return fmt.Errorf("%s was taken over by %s while this was written", Key{d.Kind, d.Number}, cur.Author)
}
revision = rev
}
return fmt.Errorf("the draft of %s was written by another writer three times in a row; read it and write again", Key{d.Kind, d.Number})
}
// draftOf is the draft the caller may write: it exists, and the caller is its author.
func (k *KB) draftOf(caller string, key Key, verb string) (*Draft, uint64, error) {
if !proven(caller) {
return nil, 0, fmt.Errorf("%s needs a caller the runtime proved; this call is by %s. Nothing was written", verb, caller)
}
d, rev, err := k.readDraft(key)
if err != nil {
return nil, 0, err
}
if d == nil {
return nil, 0, fmt.Errorf("%s has no draft: open it first. Nothing was written", key)
}
if d.Author != caller {
return nil, 0, fmt.Errorf("%s is a draft by %s: only its author, proven by the runtime, writes it (this call is by %s); take-over makes you its author. Nothing was written", key, d.Author, caller)
}
return d, rev, nil
}
// OpenAsk opens or changes a draft: the fields of a proposal, but its sections.
type OpenAsk = ProposeAsk
// Opened is the answer to open.
type Opened struct {
Record string `json:"record"`
Sections []string `json:"sections,omitempty"`
Says string `json:"says"`
}
// Open opens a draft of a reserved or held record, with its fields, clauses and links; called again by its author it
// changes them. A held record's draft starts from the sections of the version a writer works on.
func (k *KB) Open(caller string, a OpenAsk) (*Opened, error) {
k.mu.Lock()
defer k.mu.Unlock()
if !proven(caller) {
return nil, fmt.Errorf("open needs a caller the runtime proved; this call is by %s. Nothing was opened", caller)
}
w, err := k.writer()
if err != nil {
return nil, err
}
if !contains(Kinds, a.Kind) {
return nil, fmt.Errorf("kind is decision or research, not %q; nothing was opened", a.Kind)
}
key := Key{a.Kind, a.Number}
ctx := k.ctxFor(caller)
held, err := k.Store.Get(ctx, key)
if err != nil {
return nil, fmt.Errorf("the knowledge base cannot be read (%v); nothing was opened", err)
}
if held == nil {
return nil, fmt.Errorf("%s is held of none: reserve a number first. Nothing was opened", key)
}
if held.Current.Status == StatusReserved && proven(held.Current.ReservedBy) && held.Current.ReservedBy != caller {
return nil, fmt.Errorf("%s was reserved by %s: only who reserved a number writes under it. Nothing was opened", key, held.Current.ReservedBy)
}
d, rev, err := k.readDraft(key)
if err != nil {
return nil, err
}
now := k.now()
switch {
case d != nil && d.Author != caller:
return nil, fmt.Errorf("%s is a draft by %s: only its author writes it (this call is by %s); take-over makes you its author. Nothing was opened", key, d.Author, caller)
case d == nil:
sections, err := w.Working(ctx, key)
if err != nil {
return nil, err
}
d = &Draft{Kind: key.Kind, Number: key.Number, Author: caller, Opened: now, From: held.Versions, Sections: sections}
}
a.Sections = nil
// A research effort's new version keeps the served title, date and deciders where none are given (issue 1966).
if s := held.Current; a.Kind == KindResearch && s.Status != StatusReserved {
if strings.TrimSpace(a.Title) == "" {
a.Title = s.Title
}
if strings.TrimSpace(a.Date) == "" {
a.Date = s.Date
}
if len(a.Deciders) == 0 {
a.Deciders = append([]string(nil), s.Deciders...)
}
}
d.Fields, d.Touched = a, now
if err := k.keepDraft(d, rev); err != nil {
return nil, fmt.Errorf("the draft of %s could not be kept (%v); nothing was opened", key, err)
}
k.emit(EventOpened, held.Current, caller, fmt.Sprintf("a draft opened from version %d", d.From), d.From)
return &Opened{Record: key.String(), Sections: titlesOf(d.Sections), Says: fmt.Sprintf("A draft of %s is open, by you, starting from version %d. Write its sections one call each; submit makes it a version.", key, d.From)}, nil
}
func titlesOf(refs []sectionRef) []string {
out := make([]string, len(refs))
for i, r := range refs {
out[i] = r.Name
}
return out
}
// Written is the answer to write, remove and order.
type Written struct {
Record string `json:"record"`
Sections []string `json:"sections"`
Bytes int `json:"bytes"`
Says string `json:"says"`
}
func (k *KB) draftBytes(w SectionWriter, ctx context.Context, d *Draft) (int, error) {
n := 0
for _, r := range d.Sections {
t, err := w.SectionText(ctx, Key{d.Kind, d.Number}, r)
if err != nil {
return 0, err
}
n += len(t)
}
return n, nil
}
// Write writes one section of the draft: its title and its text, into the record's history at once.
func (k *KB) Write(caller string, key Key, title, text string) (*Written, error) {
k.mu.Lock()
defer k.mu.Unlock()
w, err := k.writer()
if err != nil {
return nil, err
}
d, rev, err := k.draftOf(caller, key, "write")
if err != nil {
return nil, err
}
title = strings.TrimSpace(title)
if title == "" {
return nil, errors.New("write needs the section's title. Nothing was written")
}
if len(text) > MostSectionBytes {
return nil, fmt.Errorf("the section %q is %d bytes; a section holds at most %d: split it. Nothing was written", title, len(text), MostSectionBytes)
}
// A section is a part with a title, never a file: learned on the section just written (to-be 55 §2.2, §4).
if problems := sectionFormProblems(title, text); len(problems) > 0 {
return nil, fmt.Errorf("the section is refused, and nothing was written:\n- %s", strings.Join(problems, "\n- "))
}
ctx := k.ctxFor(caller)
at := -1
for i, r := range d.Sections {
if r.Name == title {
at = i
}
}
if at < 0 && len(d.Sections) >= MostDraftSections {
return nil, fmt.Errorf("a draft holds at most %d sections. Nothing was written", MostDraftSections)
}
ref, err := w.WriteSection(ctx, key, title, text)
if err != nil {
return nil, fmt.Errorf("the section %q could not be written (%v); nothing was written", title, err)
}
if at >= 0 {
d.Sections[at] = ref
} else {
d.Sections = append(d.Sections, ref)
}
n, err := k.draftBytes(w, ctx, d)
if err != nil {
return nil, err
}
if n > MostDraftBytes {
return nil, fmt.Errorf("a draft holds at most %d bytes; this would hold %d. Nothing was kept in the draft (the section stays in the history, named by no version)", MostDraftBytes, n)
}
d.Touched = k.now()
if err := k.keepDraft(d, rev); err != nil {
return nil, fmt.Errorf("the draft of %s could not be kept (%v)", key, err)
}
k.emit(EventWritten, &Record{Kind: key.Kind, Number: key.Number}, caller, fmt.Sprintf("the section %q, %s", title, ref.Digest), 0)
return &Written{Record: key.String(), Sections: titlesOf(d.Sections), Bytes: n, Says: fmt.Sprintf("The section %q is written (operation %d).", title, ref.Sequence)}, nil
}
// Remove removes one section from the draft.
func (k *KB) Remove(caller string, key Key, title string) (*Written, error) {
k.mu.Lock()
defer k.mu.Unlock()
w, err := k.writer()
if err != nil {
return nil, err
}
d, rev, err := k.draftOf(caller, key, "remove")
if err != nil {
return nil, err
}
var kept []sectionRef
for _, r := range d.Sections {
if r.Name != title {
kept = append(kept, r)
}
}
if len(kept) == len(d.Sections) {
return nil, fmt.Errorf("the draft of %s has no section %q (it has %s). Nothing was removed", key, title, strings.Join(titlesOf(d.Sections), ", "))
}
d.Sections, d.Touched = kept, k.now()
if err := k.keepDraft(d, rev); err != nil {
return nil, err
}
n, _ := k.draftBytes(w, k.ctxFor(caller), d)
k.emit(EventRemoved, &Record{Kind: key.Kind, Number: key.Number}, caller, fmt.Sprintf("the section %q", title), 0)
return &Written{Record: key.String(), Sections: titlesOf(d.Sections), Bytes: n, Says: fmt.Sprintf("The section %q is removed from the draft.", title)}, nil
}
// Order sets the sections' order: every title the draft holds, once.
func (k *KB) Order(caller string, key Key, titles []string) (*Written, error) {
k.mu.Lock()
defer k.mu.Unlock()
w, err := k.writer()
if err != nil {
return nil, err
}
d, rev, err := k.draftOf(caller, key, "order")
if err != nil {
return nil, err
}
have := titlesOf(d.Sections)
a, b := append([]string(nil), have...), append([]string(nil), titles...)
sort.Strings(a)
sort.Strings(b)
if strings.Join(a, "\x00") != strings.Join(b, "\x00") {
return nil, fmt.Errorf("order names every section of the draft once; the draft holds %s. Nothing was reordered", strings.Join(have, ", "))
}
byName := map[string]sectionRef{}
for _, r := range d.Sections {
byName[r.Name] = r
}
d.Sections = d.Sections[:0:0]
for _, t := range titles {
d.Sections = append(d.Sections, byName[t])
}
d.Touched = k.now()
if err := k.keepDraft(d, rev); err != nil {
return nil, err
}
n, _ := k.draftBytes(w, k.ctxFor(caller), d)
k.emit(EventOrdered, &Record{Kind: key.Kind, Number: key.Number}, caller, strings.Join(titles, ", "), 0)
return &Written{Record: key.String(), Sections: titles, Bytes: n, Says: "The sections are in the order given."}, nil
}
// Dropped is the answer to drop.
type Dropped struct {
Record string `json:"record"`
Says string `json:"says"`
}
// DropDraft discards the draft; its sections stay in the history, named by no version.
func (k *KB) DropDraft(caller string, key Key, reason string) (*Dropped, error) {
k.mu.Lock()
defer k.mu.Unlock()
if _, err := k.writer(); err != nil {
return nil, err
}
if _, _, err := k.draftOf(caller, key, "drop"); err != nil {
return nil, err
}
return k.dropped(caller, key, strings.TrimSpace(reason), "dropped by its author")
}
func (k *KB) dropped(by string, key Key, reason, what string) (*Dropped, error) {
kv, err := k.draftState()
if err != nil {
return nil, err
}
if err := kv.Delete(draftKey(key)); err != nil {
return nil, err
}
if reason != "" {
what += ": " + reason
}
k.emit(EventDropped, &Record{Kind: key.Kind, Number: key.Number}, by, what, 0)
return &Dropped{Record: key.String(), Says: "The draft of " + key.String() + " is gone."}, nil
}
// TakenOver is the answer to take-over.
type TakenOver struct {
Record string `json:"record"`
From string `json:"from"`
Says string `json:"says"`
}
// TakeOver makes the caller the author of a draft or of a proposal, with a reason; the reason is carried in the next
// version's message and the previous author is told by the event `taken-over`. A proposal waiting for the operator's
// Approve is not taken over: its author withdraws it first.
func (k *KB) TakeOver(caller string, key Key, reason string) (*TakenOver, error) {
k.mu.Lock()
defer k.mu.Unlock()
if !proven(caller) {
return nil, fmt.Errorf("take-over needs a caller the runtime proved; this call is by %s. Nothing was taken over", caller)
}
reason = strings.TrimSpace(reason)
if reason == "" {
return nil, errors.New("take-over needs the reason, in one line. Nothing was taken over")
}
if _, err := k.writer(); err != nil {
return nil, err
}
ctx := k.ctxFor(caller)
held, err := k.Store.Get(ctx, key)
if err != nil {
return nil, err
}
if held == nil {
return nil, fmt.Errorf("%s is held of none. Nothing was taken over", key)
}
if held.Working().Ask != nil && held.Working().Ask.Said == "waiting" {
return nil, fmt.Errorf("%s waits for the operator's Approve: its author withdraws it first. Nothing was taken over", key)
}
d, rev, err := k.readDraft(key)
if err != nil {
return nil, err
}
var from string
switch {
case d != nil:
from = d.Author
d.Author, d.TakenOver, d.Touched = caller, reason, k.now()
if err := k.keepDraft(d, rev); err != nil {
return nil, err
}
if held.Current.Status == StatusReserved {
// Only who reserved a number proposes under it: the number is the new author's.
w := held.Current.Clone()
w.ReservedBy = caller
if err := k.Store.Save(ctx, Version{By: caller, At: k.now(), Why: "taken over from " + from + ": " + reason, Record: w}, true, false, held.Revision); err != nil {
return nil, fmt.Errorf("the number of %s could not be handed over (%v)", key, err)
}
}
case held.Working().Status == StatusProposed:
w := held.Working().Clone()
from = w.Author
w.Author = caller
if !contains(w.Writers, caller) {
w.Writers = append(w.Writers, caller)
}
current := held.Pending == nil
if err := k.Store.Save(ctx, Version{By: caller, At: k.now(), Why: "taken over from " + from + ": " + reason, Record: w}, current, false, held.Revision); err != nil {
return nil, fmt.Errorf("%s could not be taken over (%v)", key, err)
}
default:
return nil, fmt.Errorf("%s has no draft and is %s, not proposed: there is nothing to take over", key, held.Working().Status)
}
if from == caller {
return nil, fmt.Errorf("%s is already yours", key)
}
k.emit(EventTakenOver, held.Current, caller, fmt.Sprintf("from %s: %s", from, reason), held.Versions)
return &TakenOver{Record: key.String(), From: from, Says: fmt.Sprintf("You are the author of %s now; %s is told. The reason goes in its next version's message.", key, from)}, nil
}
// SubmitDraft makes the draft a version: it is judged by the rules a proposal is, every reason of a refusal named at
// once and the draft kept as it was, and the review asked.
func (k *KB) SubmitDraft(caller string, key Key, why string) (*Proposed, error) {
w, err := k.writer()
if err != nil {
return nil, err
}
k.mu.Lock()
d, _, err := k.draftOf(caller, key, "submit")
k.mu.Unlock()
if err != nil {
return nil, err
}
a := d.Fields
a.Kind, a.Number = key.Kind, key.Number
if strings.TrimSpace(why) != "" {
a.Why = why
} else if d.TakenOver != "" && a.Why == "" {
a.Why = "revised after a take-over: " + d.TakenOver
}
ctx := k.ctxFor(caller)
for _, r := range d.Sections {
t, err := w.SectionText(ctx, key, r)
if err != nil {
return nil, err
}
a.Sections = append(a.Sections, Section{Name: r.Name, Text: t})
}
w.Known(key, d.Sections)
out, err := k.Propose(caller, a)
if err != nil {
return nil, err
}
k.mu.Lock()
defer k.mu.Unlock()
if _, err := k.dropped(caller, key, "", "submitted"); err != nil {
k.logf("[mesh-knowledge] the draft of %s could not be removed after it was submitted: %v", key, err)
}
k.emit(EventSubmitted, &Record{Kind: key.Kind, Number: key.Number, Status: out.Status}, caller, fmt.Sprintf("version %d, %d sections", out.Version, len(a.Sections)), out.Version)
return out, nil
}