@@ -706,10 +706,18 @@ static const tool_def_t TOOLS[] = {
706706
707707 {"manage_adr", "Manage ADR", "Create or update Architecture Decision Records",
708708 "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"mode\":{\"type\":"
709- "\"string\",\"enum\":[\"get\",\"update\",\"sections\"],\"description\":\"update replaces "
710- "the entire ADR document; sections only lists existing "
711- "headings\"},\"content\":{\"type\":\"string\",\"description\":\"Complete replacement document "
712- "required by update\"}},\"additionalProperties\":false,"
709+ "\"string\",\"enum\":[\"get\",\"update\",\"set_sections\",\"sections\"],\"description\":"
710+ "\"update REPLACES the entire ADR document; set_sections rewrites only the named sections "
711+ "and leaves every other byte of the stored document untouched, so adding one entry does not "
712+ "mean re-sending the whole ADR (setting the same section to the same body twice leaves the "
713+ "document byte-identical, so retrying after a lost response is safe); sections only lists "
714+ "existing headings\"},\"content\":{\"type\":\"string\",\"description\":\"Complete replacement "
715+ "document required by update\"},\"section_updates\":{\"type\":\"object\",\"description\":"
716+ "\"Required by set_sections: section name -> new body for that section. Any heading name "
717+ "works, so a new entry can be added under its own heading; PURPOSE, STACK, ARCHITECTURE, "
718+ "PATTERNS, TRADEOFFS and PHILOSOPHY are the conventional ones. Names match exactly, "
719+ "including case, so 'Purpose' and 'PURPOSE' are different sections.\","
720+ "\"additionalProperties\":{\"type\":\"string\"}}},\"additionalProperties\":false,"
713721 "\"required\":[\"project\"]}"},
714722
715723 {"ingest_traces", "Ingest traces", "Ingest runtime traces to enhance the knowledge graph",
@@ -11754,31 +11762,33 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
1175411762
1175511763/* ── manage_adr ───────────────────────────────────────────────── */
1175611764
11757- /* ADR "sections" mode: list markdown headers ('#'-prefixed lines) from the
11758- * ADR content string. */
11765+ typedef struct {
11766+ yyjson_mut_doc *doc;
11767+ yyjson_mut_val *arr;
11768+ } adr_sections_ctx_t;
11769+
11770+ static void adr_sections_cb(void *ctx, const cbm_adr_heading_t *h) {
11771+ adr_sections_ctx_t *c = (adr_sections_ctx_t *)ctx;
11772+ char hdr[CBM_SZ_1K];
11773+ snprintf(hdr, sizeof(hdr), "## %.*s", h->name_len, h->name);
11774+ yyjson_mut_arr_add_strcpy(c->doc, c->arr, hdr);
11775+ }
11776+
11777+ /* ADR "sections" mode: list the section headings of the ADR.
11778+ *
11779+ * This uses cbm_adr_scan_headings(), the SAME classifier the section-write
11780+ * path splices with. It used to list any '#'-prefixed line, so a '## Foo' in
11781+ * prose — or inside a fenced code block — was reported as a section that no
11782+ * write could target. Two components disagreeing about what a section is was
11783+ * how a section write came to be able to destroy one. */
1175911784static void adr_list_sections_from_content(yyjson_mut_doc *doc, yyjson_mut_val *root_obj,
1176011785 const char *content) {
1176111786 yyjson_mut_val *sections = yyjson_mut_arr(doc);
11762- const char *p = content;
11763- while (p && *p) {
11764- const char *eol = strchr(p, '\n');
11765- size_t linelen = eol ? (size_t)(eol - p) : strlen(p);
11766- while (linelen > 0 && p[linelen - SKIP_ONE] == '\r') {
11767- linelen--;
11768- }
11769- if (linelen > 0 && p[0] == '#') {
11770- char hdr[CBM_SZ_1K];
11771- if (linelen >= sizeof(hdr)) {
11772- linelen = sizeof(hdr) - SKIP_ONE;
11773- }
11774- memcpy(hdr, p, linelen);
11775- hdr[linelen] = '\0';
11776- yyjson_mut_arr_add_strcpy(doc, sections, hdr);
11777- }
11778- if (!eol) {
11779- break;
11780- }
11781- p = eol + SKIP_ONE;
11787+ adr_sections_ctx_t ctx = {doc, sections};
11788+ if (content && cbm_adr_scan_headings(content, adr_sections_cb, &ctx) != CBM_STORE_OK) {
11789+ /* The ambiguity that refuses a section write is reported here too,
11790+ * rather than answering with a heading list that is quietly partial. */
11791+ yyjson_mut_obj_add_str(doc, root_obj, "sections_status", "unterminated_code_fence");
1178211792 }
1178311793 yyjson_mut_obj_add_val(doc, root_obj, "sections", sections);
1178411794}
@@ -11848,6 +11858,123 @@ static cbm_store_t *open_adr_store_for_write(cbm_mcp_server_t *srv, cbm_store_t
1184811858 return *owned_rw;
1184911859}
1185011860
11861+ /* Parsed `section_updates` for mode='set_sections'.
11862+ *
11863+ * mode='update' replaces the whole document, so adding one entry costs a full
11864+ * re-send and the stored ADR is only ever as good as that round-trip. Writing
11865+ * named sections instead leaves the rest of the document as the authority for
11866+ * itself — and, unlike a whole-document append, applying the same request
11867+ * twice yields the same document, so a client that retries after a lost
11868+ * response cannot silently duplicate content. */
11869+ typedef struct {
11870+ char *keys[PROPS_MAX];
11871+ char *values[PROPS_MAX];
11872+ int count;
11873+ /* Rejection reason, or NULL when the request parsed cleanly. Set means no
11874+ * store was opened and nothing was written. */
11875+ const char *status;
11876+ const char *error;
11877+ } adr_section_updates_t;
11878+
11879+ static void adr_section_updates_free(adr_section_updates_t *u) {
11880+ for (int i = 0; i < u->count; i++) {
11881+ free(u->keys[i]);
11882+ free(u->values[i]);
11883+ }
11884+ u->count = 0;
11885+ }
11886+
11887+ static bool adr_collect_section_update(adr_section_updates_t *u, yyjson_val *key, yyjson_val *val) {
11888+ const char *name = yyjson_get_str(key);
11889+ if (!name || !name[0]) {
11890+ u->status = "invalid_section_updates";
11891+ u->error = "'section_updates' keys must be non-empty section names. "
11892+ "No ADR write was performed.";
11893+ return false;
11894+ }
11895+ if (!yyjson_is_str(val)) {
11896+ u->status = "invalid_section_updates";
11897+ u->error = "'section_updates' values must be strings (the new body for that section). "
11898+ "No ADR write was performed.";
11899+ return false;
11900+ }
11901+ const char *body = yyjson_get_str(val);
11902+ /* An empty body would render a heading with nothing under it — a silent
11903+ * content deletion wearing the response shape of an update. Clearing a
11904+ * section is whole-document surgery; that is what mode='update' is for. */
11905+ if (!body || !body[0]) {
11906+ u->status = "empty_section_content";
11907+ u->error = "'section_updates' values must be non-empty; use mode='update' to remove a "
11908+ "section. No ADR write was performed.";
11909+ return false;
11910+ }
11911+ if (u->count >= PROPS_MAX) {
11912+ u->status = "too_many_sections";
11913+ u->error = "'section_updates' carries more entries than an ADR can hold. "
11914+ "No ADR write was performed.";
11915+ return false;
11916+ }
11917+ u->keys[u->count] = heap_strdup(name);
11918+ u->values[u->count] = heap_strdup(body);
11919+ if (!u->keys[u->count] || !u->values[u->count]) {
11920+ free(u->keys[u->count]);
11921+ free(u->values[u->count]);
11922+ u->status = "write_error";
11923+ u->error = "out of memory parsing 'section_updates'. No ADR write was performed.";
11924+ return false;
11925+ }
11926+ u->count++;
11927+ return true;
11928+ }
11929+
11930+ static adr_section_updates_t adr_parse_section_updates(const char *args) {
11931+ adr_section_updates_t u;
11932+ memset(&u, 0, sizeof(u));
11933+
11934+ yyjson_doc *doc = yyjson_read(args, strlen(args), 0);
11935+ yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
11936+ yyjson_val *updates =
11937+ (root && yyjson_is_obj(root)) ? yyjson_obj_get(root, "section_updates") : NULL;
11938+
11939+ if (!updates) {
11940+ /* Never fall through to 'get': a caller that meant to write must not
11941+ * receive a success-shaped read. */
11942+ u.status = "missing_section_updates";
11943+ u.error = "mode='set_sections' requires 'section_updates', an object mapping section "
11944+ "name to its new body. No ADR write was performed.";
11945+ } else if (!yyjson_is_obj(updates) || yyjson_obj_size(updates) == 0) {
11946+ u.status = "invalid_section_updates";
11947+ u.error = "'section_updates' must be a non-empty object mapping section name to its new "
11948+ "body. No ADR write was performed.";
11949+ } else {
11950+ size_t idx = 0;
11951+ size_t max = 0;
11952+ yyjson_val *key = NULL;
11953+ yyjson_val *val = NULL;
11954+ yyjson_obj_foreach(updates, idx, max, key, val) {
11955+ if (!adr_collect_section_update(&u, key, val)) {
11956+ adr_section_updates_free(&u);
11957+ break;
11958+ }
11959+ }
11960+ }
11961+ yyjson_doc_free(doc);
11962+ return u;
11963+ }
11964+
11965+ /* Build the rejection payload for a set_sections request that never reached a
11966+ * store. Caller frees. */
11967+ static char *adr_section_updates_error(const adr_section_updates_t *u) {
11968+ yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
11969+ yyjson_mut_val *root_obj = yyjson_mut_obj(doc);
11970+ yyjson_mut_doc_set_root(doc, root_obj);
11971+ yyjson_mut_obj_add_str(doc, root_obj, "status", u->status);
11972+ yyjson_mut_obj_add_strcpy(doc, root_obj, "error", u->error);
11973+ char *json = yy_doc_to_str(doc);
11974+ yyjson_mut_doc_free(doc);
11975+ return json;
11976+ }
11977+
1185111978static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1185211979 char *project = get_project_arg(args);
1185311980 char *mode_str = cbm_mcp_get_string_arg(args, "mode");
@@ -11879,12 +12006,51 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1187912006 true);
1188012007 }
1188112008
12009+ bool set_sections_mode = (strcmp(mode_str, "set_sections") == 0);
12010+ adr_section_updates_t updates;
12011+ memset(&updates, 0, sizeof(updates));
12012+ char section_key_err[CBM_SZ_256] = "";
12013+ if (set_sections_mode) {
12014+ updates = adr_parse_section_updates(args);
12015+ /* Any heading name is writable — the canonical six are a convention,
12016+ * not a privilege. What is still refused is a name that could not
12017+ * round-trip through a "## NAME" line (empty, '#'-leading, newline- or
12018+ * edge-whitespace-bearing, over-long): such a name would scan back as
12019+ * a different heading or as none, so a second identical write would
12020+ * append a duplicate instead of being a no-op. */
12021+ if (!updates.status && cbm_adr_validate_section_keys(
12022+ (const char **)updates.keys, updates.count, section_key_err,
12023+ (int)sizeof(section_key_err)) != CBM_STORE_OK) {
12024+ adr_section_updates_free(&updates);
12025+ updates.status = "invalid_section_name";
12026+ updates.error = section_key_err;
12027+ }
12028+ if (updates.status) {
12029+ /* Reject before taking the project lease or opening a store: a
12030+ * malformed write must not block an index, and must not read. */
12031+ char *err = adr_section_updates_error(&updates);
12032+ adr_section_updates_free(&updates);
12033+ free(project);
12034+ free(mode_str);
12035+ free(content);
12036+ char *res = cbm_mcp_text_result(err, true);
12037+ free(err);
12038+ return res;
12039+ }
12040+ }
12041+
12042+ /* This classification is load-bearing. A mode missing from it takes no
12043+ * per-project mutation lease, resolves the store query-only, and never
12044+ * reaches open_adr_store_for_write — so its write would be attempted
12045+ * through a read-only handle, concurrently with an active index. */
1188212046 bool write_request =
11883- content && (strcmp(mode_str, "update") == 0 || strcmp(mode_str, "store") == 0);
12047+ (content && (strcmp(mode_str, "update") == 0 || strcmp(mode_str, "store") == 0)) ||
12048+ set_sections_mode;
1188412049 bool mutation_held = false;
1188512050 if (write_request && project) {
1188612051 mutation_held = mcp_project_mutation_begin(srv, project);
1188712052 if (!mutation_held) {
12053+ adr_section_updates_free(&updates);
1188812054 free(project);
1188912055 free(mode_str);
1189012056 free(content);
@@ -11893,6 +12059,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1189312059 }
1189412060 if (mcp_request_cancelled(srv)) {
1189512061 mcp_project_mutation_end(srv, project);
12062+ adr_section_updates_free(&updates);
1189612063 free(project);
1189712064 free(mode_str);
1189812065 free(content);
@@ -11921,6 +12088,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1192112088 if (mutation_held) {
1192212089 mcp_project_mutation_end(srv, project);
1192312090 }
12091+ adr_section_updates_free(&updates);
1192412092 free(project);
1192512093 free(mode_str);
1192612094 free(content);
@@ -11935,6 +12103,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1193512103 if (mutation_held) {
1193612104 mcp_project_mutation_end(srv, project);
1193712105 }
12106+ adr_section_updates_free(&updates);
1193812107 free(project);
1193912108 free(mode_str);
1194012109 free(content);
@@ -11981,13 +12150,69 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1198112150 }
1198212151 }
1198312152
12153+ /* A set_sections write must see a legacy file-backed ADR too. The
12154+ * migration above deliberately runs on the read path only — it must never
12155+ * block on the lease — so the write path reads the file here, where the
12156+ * exclusive project lease and a writable store are already held. Merging
12157+ * onto an empty document instead would silently discard an ADR the user
12158+ * still has on disk. */
12159+ char *legacy_seed = NULL;
12160+ if (set_sections_mode && !have_adr) {
12161+ char *root_path = project_root_from_store(store, project);
12162+ legacy_seed = adr_read_legacy_file(root_path);
12163+ free(root_path);
12164+ }
12165+
1198412166 yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
1198512167 yyjson_mut_val *root_obj = yyjson_mut_obj(doc);
1198612168 yyjson_mut_doc_set_root(doc, root_obj);
1198712169
1198812170 bool is_error = false;
1198912171 const char *adr_content = have_adr ? adr.content : legacy;
11990- if (write_request) {
12172+ if (set_sections_mode) {
12173+ /* cbm_store_adr_update_sections requires an existing row — its
12174+ * contract, pinned by TEST(adr_update_no_existing). Seed one when the
12175+ * project has none, so the mode degrades to a plain create: the legacy
12176+ * document when there is one, an empty document otherwise. */
12177+ bool base_present = have_adr;
12178+ bool seeded_empty = false;
12179+ if (!base_present) {
12180+ const char *seed = legacy_seed ? legacy_seed : "";
12181+ if (cbm_store_adr_store(store, project, seed) == CBM_STORE_OK) {
12182+ base_present = true;
12183+ seeded_empty = (legacy_seed == NULL);
12184+ }
12185+ }
12186+ cbm_adr_t updated;
12187+ memset(&updated, 0, sizeof(updated));
12188+ int section_rc = base_present ? cbm_store_adr_update_sections(
12189+ store, project, (const char **)updates.keys,
12190+ (const char **)updates.values, updates.count, &updated)
12191+ : CBM_STORE_ERR;
12192+ if (section_rc == CBM_STORE_OK) {
12193+ yyjson_mut_obj_add_str(doc, root_obj, "status", "sections_updated");
12194+ yyjson_mut_obj_add_str(doc, root_obj, "semantics",
12195+ "named_sections_replaced_rest_preserved");
12196+ yyjson_mut_obj_add_uint(doc, root_obj, "sections_written", (uint64_t)updates.count);
12197+ /* Callers confirm the write landed without re-fetching the ADR. */
12198+ yyjson_mut_obj_add_uint(doc, root_obj, "content_length",
12199+ (uint64_t)strlen(updated.content));
12200+ cbm_store_adr_free(&updated);
12201+ } else {
12202+ /* Undo an empty seed. A rejected write must not leave the project
12203+ * holding a blank ADR where `get` used to answer no_adr. A legacy
12204+ * seed is a real migration and is kept. */
12205+ if (seeded_empty) {
12206+ (void)cbm_store_adr_delete(store, project);
12207+ }
12208+ yyjson_mut_obj_add_str(doc, root_obj, "status", "write_error");
12209+ const char *store_err = cbm_store_error(store);
12210+ if (store_err && store_err[0]) {
12211+ yyjson_mut_obj_add_strcpy(doc, root_obj, "error", store_err);
12212+ }
12213+ is_error = true;
12214+ }
12215+ } else if (write_request) {
1199112216 if (cbm_store_adr_store(store, project, content) == CBM_STORE_OK) {
1199212217 yyjson_mut_obj_add_str(doc, root_obj, "status", "updated");
1199312218 yyjson_mut_obj_add_str(doc, root_obj, "semantics", "whole_document_replaced");
@@ -12018,6 +12243,8 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1201812243 if (mutation_held) {
1201912244 mcp_project_mutation_end(srv, project);
1202012245 }
12246+ adr_section_updates_free(&updates);
12247+ free(legacy_seed);
1202112248 free(legacy);
1202212249 free(project);
1202312250 free(mode_str);
0 commit comments