Add mkcapsule --resolve: build-time claim registry for capsule block collisions (FABRIC-3.md §XXIV)
Traced what a "Block NNNN" collision actually means before designing a fix for it: capsule_loader.c's block-write path routes through the generic block-subsystem API, which kernel_main.c registers as two devices in a fixed order -- the volatile ramdrive first (LBN 2048-3071), then Artemis's real virtio-blk device immediately after (LBN 3072+, backed by disk/artemis.img). Every capsule this project has lands in Artemis's persistent range, not the ramdrive, and blk_update()'s dirty-marking + repl.c's idle-loop flush write that content through to the real disk file on every boot. A block-number collision is therefore a silent, persistent overwrite of real disk content surviving reboots, not a transient RAM mixup. The existing collision gate (check_block_conflicts(), already a hard non-interactive build failure) already catches capsule-vs-capsule collisions across the whole flat range. The real gap: zero visibility into blocks something other than a capsule owns (Artemis's own non-capsule persistent data), and no device-boundary/capacity awareness at all. Added, scoped step by step before writing any code: - tools/capsule-claims.txt -- derived, auto-created/regenerated, git-ignored. Lets --resolve tell "this capsule's own content changed" apart from "genuinely new collision with something else." - tools/capsule-reserved.txt -- human-authored, git-tracked, seeded with nothing yet rather than guessed at. Checked by both the plain build gate (new check_reserved_conflicts()) and --resolve. - tools/patches/ -- git-tracked, one file per accepted interactive renumber; a structured old->new block list, not a generic diff, since that's the only thing a renumber ever changes. - mkcapsule --resolve <dir> -- the only interactive mkcapsule mode, a deliberate separate invocation from the plain build path (which stays non-interactive so CI never blocks on a prompt). Suggests a renumbering that preserves a capsule's own existing block spacing, prompts y/N, rewrites the .4th source in place on acceptance. Found and fixed a real bug during verification: the registry's empty-block-list case (workload-5.4th, zero Block headers) serialized with a stray trailing space that the reader parsed back as a phantom block 0, causing spurious re-registration every run -- caught by testing idempotency directly, not assuming a clean first run meant it worked. Verified: isolated collision tests confirm both accept and reject paths, confirm a resolved collision doesn't re-prompt the other side, confirm reserved-range collisions are caught by both --resolve and the plain build gate. Full 3-architecture rebuild via the real Makefile.starkernel succeeded clean; amd64 boots with an unchanged dict_hash/capsule_hash from every prior boot this session. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EXieurDfDSsDFdnSyusuWo
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c8ba8832c4
commit
cd2fda4351
@@ -68,3 +68,9 @@ tools/kconfig/.mconf.*
|
||||
tools/kconfig/.qconf.*
|
||||
/.config
|
||||
/.config.old
|
||||
|
||||
# mkcapsule --resolve claim registry (FABRIC-3.md SXXIV) — pure derived
|
||||
# state, auto-created/regenerated, never a source of truth. Its sibling
|
||||
# tools/capsule-reserved.txt (human-authored) and tools/patches/ (decision
|
||||
# record) are NOT ignored — those are tracked deliberately.
|
||||
/tools/capsule-claims.txt
|
||||
|
||||
+105
@@ -3096,3 +3096,108 @@ the current `capsules/` tree or `doe.4th`'s own CSV header -- corrected to descr
|
||||
actually there. Verified: `mkcapsule --lint` clean, all 3 architectures build with zero new
|
||||
warnings, amd64 boots with an unchanged dict_hash/capsule_hash from every prior boot this session.
|
||||
|
||||
## XXIV. `mkcapsule --resolve`: block-number collisions are silent persistent-disk corruption,
|
||||
not a transient RAM mixup -- traced fully, then given a real build-time fix (2026-09-12)
|
||||
|
||||
**Scoping "add 3 workloads into the std79 factorial" surfaced a much bigger, longstanding
|
||||
question.** Discussing which workloads to add and how (a genuine 5th factor was identified in
|
||||
the process -- workload identity × placement mode, both worth varying independently, not
|
||||
guessed at) led directly to Captain Bob's own instinct: "block collision is becoming an issue."
|
||||
That was worth tracing to ground truth before designing anything, not just reassured against.
|
||||
|
||||
**`Block NNNN` is not a compile-time label -- it's a real, addressed, persistent storage
|
||||
write, confirmed by reading the actual write path rather than assumed from the file format
|
||||
convention.** `capsule_loader.c`'s block-write function (`write_ramdrive_block`, a legacy name --
|
||||
its behavior is fully generic) calls `blk_get_buffer()`/`blk_update()`, the same block-subsystem
|
||||
API every device uses. `kernel_main.c`'s own boot sequence registers exactly two devices, in a
|
||||
fixed, confirmed order: the volatile ramdrive first (`capsule_blk_init`, LBN 2048-3071, resets
|
||||
every boot), then Artemis's real virtio-blk device immediately after (LBN 3072 onward, ~22,998
|
||||
blocks per an earlier boot log) -- backed by the actual `disk/artemis.img` this repo has carried
|
||||
in git all session. Every capsule this project has (`acl-std79.4th` at 4023+, the workload
|
||||
capsules at 4406+/4606+, `messaging.4th` at 5003+) lands in Artemis's range, not the ramdrive.
|
||||
`blk_update()` marks the target block dirty in Artemis's real BAM; `repl.c`'s idle loop
|
||||
(`blk_vm_flush_all()`, ~1 Hz) writes dirty blocks through to the actual disk file. **A block-
|
||||
number collision between two capsules is therefore a silent, persistent overwrite of real disk
|
||||
content, on every boot, surviving reboots until something else happens to rewrite that block --**
|
||||
not a transient RAM mixup that clears on restart, which is what "collision" had been read as
|
||||
before this was traced.
|
||||
|
||||
**The existing collision gate (`check_block_conflicts()`, already a hard, non-interactive build
|
||||
failure -- not new) does catch this, for capsule-vs-capsule collisions specifically.** It's blind
|
||||
to device topology entirely (treats every `Block NNNN` across every `.4th` file as one flat
|
||||
integer space), which happens to be an accurate model of the real system's own flat LBN
|
||||
addressing. The real gap: it only knows about `.4th` files it scans. It has zero visibility into
|
||||
Artemis's own *non-capsule* persistent data (identity records, home-blocks fence, etc. -- real
|
||||
data, not a `.4th` source file), and no awareness of the ramdrive/Artemis device boundary or
|
||||
capacity limits at all.
|
||||
|
||||
**Built, scoped step by step with Captain Bob before any code, per the same discipline as every
|
||||
other fix this session:** a proper build-time linker for capsule block claims, not a runtime
|
||||
mechanism -- "mkcapsule is our secret sauce linker" was the framing that stuck. Three pieces,
|
||||
deliberately separated by persistence model:
|
||||
|
||||
- **`tools/capsule-claims.txt`** -- fully derived, auto-created/regenerated, git-ignored.
|
||||
Capsule name → content hash → claimed blocks. Exists so the interactive resolver (below) can
|
||||
tell "this capsule's own content changed" apart from "this is a genuinely new collision with
|
||||
something else" -- the plain build gate doesn't need this distinction (it just re-scans and
|
||||
hard-fails every time), only the interactive tool does.
|
||||
- **`tools/capsule-reserved.txt`** -- human-authored, git-tracked, seeded with nothing yet
|
||||
(deliberately -- guessing at Artemis's real block usage would be worse than admitting it isn't
|
||||
known yet). Format: `START-END description` per line. Both the plain build gate and
|
||||
`--resolve` check every capsule's blocks against it.
|
||||
- **`tools/patches/`** -- git-tracked, one file per *accepted* interactive renumber. Not a
|
||||
generic line-diff (a renumber only ever changes `Block N` integers, never content, so a
|
||||
structured `old -> new` list is more directly useful than a diff would be) -- a permanent
|
||||
decision record, separate from the disposable registry.
|
||||
|
||||
**`mkcapsule --resolve <dir>`** is the only interactive mkcapsule mode, and deliberately a
|
||||
*separate* invocation from the plain `make` build path -- a human runs it on purpose when adding
|
||||
something new; CI/unattended builds never see a prompt. Auto-creates the claims registry from a
|
||||
fresh scan if missing. For each capsule: unchanged (same hash, same blocks as last run) is a
|
||||
silent no-op; changed-but-still-free is a silent re-claim (logged, no prompt -- moving your own
|
||||
capsule's own content around isn't a conflict); a genuine collision (with another capsule's
|
||||
*current* blocks, or a reserved range) computes a suggested renumbering that preserves the
|
||||
capsule's own existing block spacing (shifts the whole set by one delta, rather than reassigning
|
||||
each block independently, so `workload-3.4th`'s 4606,4615,4625,...,4665 convention survives a
|
||||
renumber intact) and prompts y/N. Accepting rewrites the `.4th` source in place and writes the
|
||||
patch record; rejecting or finding no free shift within a bounded search leaves everything
|
||||
untouched and marks the run failed.
|
||||
|
||||
**The plain build gate gained the reserved-range check too** (`check_reserved_conflicts()`,
|
||||
same hard-fail, non-interactive shape as the existing capsule-vs-capsule gate) -- closing half
|
||||
of the visibility gap immediately, with the other half (auto-discovering Artemis's own real
|
||||
block usage) left honestly unseeded rather than guessed at.
|
||||
|
||||
**A real bug found and fixed during verification, not just a clean first pass:** the registry's
|
||||
own empty-block-list case (`workload-5.4th`, a raw code capsule with zero `Block N` headers)
|
||||
serialized with a stray trailing space, which the reader parsed back as a phantom block `0` --
|
||||
causing every re-run to spuriously "re-register" that one capsule forever. Caught by testing
|
||||
idempotency directly (run `--resolve` twice with nothing changed, expect zero registrations the
|
||||
second time) rather than assuming a clean first run meant the mechanism was correct. Fixed in
|
||||
both the writer (no trailing space when a capsule has zero blocks) and the reader (defensively
|
||||
skip whitespace-only tokens regardless).
|
||||
|
||||
**Verified end to end, not just compiled:** an isolated test directory with a deliberate
|
||||
capsule-vs-capsule collision confirmed both the reject path (declines, exits non-zero, zero
|
||||
file changes) and the accept path (rewrites the source in place, writes the patch, and --
|
||||
correctly -- does *not* re-prompt for the second capsule in the same collision once the first
|
||||
capsule's move already cleared it, since the live in-memory block state updates immediately
|
||||
after an accepted renumber). A second isolated test confirmed reserved-range collisions are
|
||||
caught identically by both `--resolve` and the plain build gate. Full 3-architecture rebuild via
|
||||
the real `Makefile.starkernel` (not just a standalone `gcc` compile) succeeded clean; amd64
|
||||
boot-verified with an unchanged dict_hash/capsule_hash from every prior boot this session.
|
||||
|
||||
**Explicitly deferred, not built:** a CI-side idea Bob raised -- on a missing registry or a
|
||||
fresh conflict, check whether an existing patch in `tools/patches/` already resolves it before
|
||||
failing the build outright. Reasonable, but needs its own careful design (reliably matching a
|
||||
stored patch to a specific conflict) rather than folding into this pass. Also noted for later,
|
||||
not now: Bob's idea of an LLM capsule someday improving the renumbering suggestion itself, once
|
||||
`--resolve` has real mileage on it.
|
||||
|
||||
**Next step, per Bob's own call:** `mkcapsule.c` has grown to 1600+ lines across genuinely
|
||||
separable jobs (scanning/parsing, lint, manifest, conflict resolution, C-source codegen) and is
|
||||
due for a decomposition into smaller logical units -- an ETL shape (extract: scan/parse capsule
|
||||
files; transform: lint/manifest/conflict-resolve; load: C-source generation) was the starting
|
||||
idea, to be done as its own clean pass once the workload-into-factorial design (the actual
|
||||
reason this was scoped) resumes.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Capsule Block Manifest — Auto-generated
|
||||
<!-- Generated by mkcapsule --manifest 2026-09-12T23:58:57Z -->
|
||||
<!-- Generated by mkcapsule --manifest 2026-09-13T01:55:21Z -->
|
||||
<!-- DO NOT EDIT — re-run mkcapsule --manifest to refresh. -->
|
||||
<!-- Hand-written justifications and immutability notes live -->
|
||||
<!-- in MANIFEST.md alongside this auto-generated index. -->
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
# capsule-reserved.txt — human-authored, git-tracked block ranges NOT
|
||||
# owned by any capsule (FABRIC-3.md §XXIV, 2026-09-12).
|
||||
#
|
||||
# mkcapsule can already catch a capsule colliding with ANOTHER capsule --
|
||||
# every capsule is a .4th file it scans. It has zero visibility into a
|
||||
# block something else owns: Artemis's own non-capsule persistent data
|
||||
# (identity records, home-blocks fence, etc.), or anything similar added
|
||||
# later. This file is how a human tells it.
|
||||
#
|
||||
# Format, one range per line:
|
||||
# START-END description
|
||||
#
|
||||
# START and END are inclusive logical block numbers (LBN). Lines starting
|
||||
# with '#' and blank lines are ignored. Checked by both the plain build
|
||||
# gate (check_reserved_conflicts(), hard-fails on collision) and
|
||||
# `mkcapsule --resolve` (suggests renumbering around these too).
|
||||
#
|
||||
# Known device boundaries, for reference when adding entries (not
|
||||
# reserved ranges themselves -- ramdrive is legitimately shared/reused by
|
||||
# capsules; only entries below reserve anything):
|
||||
# 2048-3071 volatile ramdrive (capsule_loader.c, KRD_MAX_BLOCKS=1024)
|
||||
# 3072+ Artemis's real virtio-blk device (persistent, disk/artemis.img)
|
||||
#
|
||||
# No ranges reserved yet -- seed this file as Artemis's own actual block
|
||||
# usage becomes known (e.g. from block_subsystem.c's BAM/home-blocks
|
||||
# fence accounting), rather than guessing.
|
||||
+478
-1
@@ -11,6 +11,18 @@
|
||||
mkcapsule --lint <capsules_dir> lint all .4th files
|
||||
mkcapsule --manifest <capsules_dir> print block manifest to stdout
|
||||
mkcapsule --manifest <capsules_dir> <out> write block manifest to file
|
||||
mkcapsule --resolve <capsules_dir> interactive block-conflict
|
||||
resolver (FABRIC-3.md SXXIV)
|
||||
|
||||
--resolve is the only interactive mode -- never part of the plain build
|
||||
path above, which stays non-interactive so CI/unattended builds never
|
||||
block on a prompt. It maintains tools/capsule-claims.txt (auto-created,
|
||||
derived, git-ignored) and checks against tools/capsule-reserved.txt
|
||||
(human-authored, git-tracked, blocks something other than a capsule
|
||||
owns -- e.g. Artemis's own non-capsule persistent data). On a genuine
|
||||
conflict it suggests a renumbering that preserves the capsule's own
|
||||
existing block spacing, prompts y/N, and on acceptance rewrites the
|
||||
.4th source in place and records the decision in tools/patches/.
|
||||
|
||||
Build mode generates a C source file containing:
|
||||
- capsule_arena[] (all payload bytes concatenated)
|
||||
@@ -40,6 +52,7 @@
|
||||
#include <ftw.h>
|
||||
#include <time.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "pkcs8_ed25519.h"
|
||||
#include "starkernel/ed25519.h"
|
||||
@@ -887,6 +900,456 @@ static int check_block_conflicts(void) {
|
||||
return conflicts > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
/*===========================================================================
|
||||
* Reserved Ranges (FABRIC-3.md SXXIV, 2026-09-12)
|
||||
*
|
||||
* capsule-reserved.txt is human-authored and git-tracked -- mkcapsule can
|
||||
* discover collisions between capsules on its own (they're all .4th files
|
||||
* it scans), but it has zero visibility into blocks something OTHER than a
|
||||
* capsule owns (Artemis's own non-capsule identity/home-blocks data, e.g.)
|
||||
* unless a human tells it. Never auto-created, never auto-modified: a
|
||||
* missing file just means "nothing reserved yet," not an error.
|
||||
*===========================================================================*/
|
||||
#define RESERVED_FILE_PATH "tools/capsule-reserved.txt"
|
||||
#define CLAIMS_FILE_PATH "tools/capsule-claims.txt"
|
||||
#define PATCHES_DIR_PATH "tools/patches"
|
||||
#define MAX_RESERVED_RANGES 256
|
||||
|
||||
typedef struct {
|
||||
int start;
|
||||
int end; /* inclusive */
|
||||
char desc[256];
|
||||
} ReservedRange;
|
||||
|
||||
static ReservedRange reserved_ranges[MAX_RESERVED_RANGES];
|
||||
static int reserved_count = 0;
|
||||
|
||||
/* load_reserved_ranges: parse "START-END description" lines, '#'-comments
|
||||
* and blank lines ignored. Missing file -> reserved_count stays 0, silently
|
||||
* (this file is optional until a human seeds it). */
|
||||
static void load_reserved_ranges(const char *path) {
|
||||
reserved_count = 0;
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return;
|
||||
|
||||
char line[512];
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
char *p = line;
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
if (*p == '#' || *p == '\n' || *p == '\0') continue;
|
||||
|
||||
int start = 0, end = 0;
|
||||
int consumed = 0;
|
||||
if (sscanf(p, "%d-%d%n", &start, &end, &consumed) != 2) continue;
|
||||
if (reserved_count >= MAX_RESERVED_RANGES) break;
|
||||
|
||||
ReservedRange *r = &reserved_ranges[reserved_count];
|
||||
r->start = start;
|
||||
r->end = end;
|
||||
p += consumed;
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
size_t dlen = strcspn(p, "\r\n");
|
||||
if (dlen >= sizeof(r->desc)) dlen = sizeof(r->desc) - 1;
|
||||
memcpy(r->desc, p, dlen);
|
||||
r->desc[dlen] = '\0';
|
||||
reserved_count++;
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
/* reserved_owner_of: returns the reserved range covering lbn, or NULL. */
|
||||
static const ReservedRange *reserved_owner_of(int lbn) {
|
||||
for (int i = 0; i < reserved_count; i++) {
|
||||
if (lbn >= reserved_ranges[i].start && lbn <= reserved_ranges[i].end)
|
||||
return &reserved_ranges[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* check_reserved_conflicts: hard build-gate companion to
|
||||
* check_block_conflicts() -- same manifest_entries scan, checked against
|
||||
* capsule-reserved.txt instead of against each other. Call AFTER
|
||||
* check_block_conflicts() has already populated manifest_entries via its
|
||||
* own nftw scan (avoids scanning the directory twice). */
|
||||
static int check_reserved_conflicts(void) {
|
||||
load_reserved_ranges(RESERVED_FILE_PATH);
|
||||
if (reserved_count == 0) return 0;
|
||||
|
||||
int conflicts = 0;
|
||||
for (int i = 0; i < manifest_count; i++) {
|
||||
for (int j = 0; j < manifest_entries[i].block_count; j++) {
|
||||
int lbn = manifest_entries[i].blocks[j];
|
||||
const ReservedRange *r = reserved_owner_of(lbn);
|
||||
if (r) {
|
||||
fprintf(stderr,
|
||||
"mkcapsule: ERROR: block %d (%s) falls inside reserved "
|
||||
"range %d-%d: %s\n",
|
||||
lbn, manifest_entries[i].name, r->start, r->end, r->desc);
|
||||
conflicts++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (conflicts > 0) {
|
||||
fprintf(stderr,
|
||||
"mkcapsule: ERROR: %d capsule block(s) collide with reserved "
|
||||
"ranges in %s\n", conflicts, RESERVED_FILE_PATH);
|
||||
}
|
||||
return conflicts > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
/*===========================================================================
|
||||
* Claim Registry + Interactive Resolve (mkcapsule --resolve)
|
||||
*
|
||||
* capsule-claims.txt is auto-created/regenerated -- pure derived state,
|
||||
* never hand-edited, git-ignored. Unlike the plain build gate (which just
|
||||
* re-scans and hard-fails every time, no memory needed), --resolve needs
|
||||
* to tell "this capsule's own content changed" apart from "a genuinely new
|
||||
* collision with something else," which needs the previous run's state.
|
||||
*===========================================================================*/
|
||||
typedef struct {
|
||||
char name[CAPSULE_NAME_MAX];
|
||||
uint64_t hash;
|
||||
int blocks[MAX_BLOCKS_PER_CAPSULE];
|
||||
int block_count;
|
||||
} ClaimEntry;
|
||||
|
||||
static ClaimEntry claim_registry[MAX_CAPSULES];
|
||||
static int claim_count = 0;
|
||||
|
||||
static ClaimEntry *find_claim(const char *name) {
|
||||
for (int i = 0; i < claim_count; i++) {
|
||||
if (strcmp(claim_registry[i].name, name) == 0) return &claim_registry[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* load_claims_registry: parse "<name> <hash-hex> <b1>,<b2>,..." lines.
|
||||
* Missing file -> claim_count stays 0 (fresh baseline, not an error --
|
||||
* this is the auto-create case). */
|
||||
static void load_claims_registry(const char *path) {
|
||||
claim_count = 0;
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return;
|
||||
|
||||
char line[2048];
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (line[0] == '#' || line[0] == '\n' || line[0] == '\0') continue;
|
||||
if (claim_count >= MAX_CAPSULES) break;
|
||||
|
||||
char name[CAPSULE_NAME_MAX];
|
||||
char hashbuf[32];
|
||||
char blocklist[1600] = "";
|
||||
int nf = sscanf(line, "%511s %31s %1599[^\n]", name, hashbuf, blocklist);
|
||||
if (nf < 2) continue; /* nf==3 has a block list; nf==2 is a valid
|
||||
* empty-block-list entry (raw code capsule,
|
||||
* no "Block N" headers at all) */
|
||||
|
||||
ClaimEntry *e = &claim_registry[claim_count];
|
||||
snprintf(e->name, CAPSULE_NAME_MAX, "%s", name);
|
||||
e->hash = strtoull(hashbuf, NULL, 16);
|
||||
e->block_count = 0;
|
||||
|
||||
char *tok = strtok(blocklist, ",");
|
||||
while (tok && e->block_count < MAX_BLOCKS_PER_CAPSULE) {
|
||||
while (*tok == ' ') tok++; /* defensive: tolerate stray
|
||||
* whitespace from older/
|
||||
* hand-written registry files */
|
||||
if (*tok == '\0') { tok = strtok(NULL, ","); continue; }
|
||||
e->blocks[e->block_count++] = (int)strtol(tok, NULL, 10);
|
||||
tok = strtok(NULL, ",");
|
||||
}
|
||||
claim_count++;
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
static void save_claims_registry(const char *path) {
|
||||
FILE *f = fopen(path, "w");
|
||||
if (!f) {
|
||||
fprintf(stderr, "mkcapsule: WARNING: could not write %s\n", path);
|
||||
return;
|
||||
}
|
||||
fprintf(f, "# DO NOT EDIT — re-run mkcapsule --resolve to refresh.\n"
|
||||
"# This file is derived state, not a source of truth (the\n"
|
||||
"# .4th files themselves are) -- git-ignored on purpose.\n");
|
||||
for (int i = 0; i < claim_count; i++) {
|
||||
ClaimEntry *e = &claim_registry[i];
|
||||
fprintf(f, "%s %016" PRIx64, e->name, e->hash);
|
||||
for (int j = 0; j < e->block_count; j++) {
|
||||
fprintf(f, "%s%d", j == 0 ? " " : ",", e->blocks[j]);
|
||||
}
|
||||
fprintf(f, "\n");
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
/* blocks_free_at_delta: true if shifting every block in `blocks` by `delta`
|
||||
* lands on numbers that are simultaneously free -- not claimed by any OTHER
|
||||
* current capsule (manifest_entries, ground truth for this run, excluding
|
||||
* `skip_idx` itself) and not inside a reserved range. */
|
||||
static int blocks_free_at_delta(const int *blocks, int block_count, int delta,
|
||||
int skip_idx) {
|
||||
for (int j = 0; j < block_count; j++) {
|
||||
int candidate = blocks[j] + delta;
|
||||
if (candidate < 2048) return 0; /* system-reserved floor */
|
||||
if (reserved_owner_of(candidate)) return 0;
|
||||
for (int i = 0; i < manifest_count; i++) {
|
||||
if (i == skip_idx) continue;
|
||||
for (int k = 0; k < manifest_entries[i].block_count; k++) {
|
||||
if (manifest_entries[i].blocks[k] == candidate) return 0;
|
||||
}
|
||||
}
|
||||
/* also check other candidates within the SAME shifted set don't
|
||||
* collide with each other (only possible if the caller passes a
|
||||
* pathological blocks[] with duplicate relative offsets) */
|
||||
for (int j2 = 0; j2 < j; j2++) {
|
||||
if (blocks[j2] + delta == candidate) return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* suggest_renumbering: preserves the capsule's own existing relative
|
||||
* spacing (workload-3.4th's 4606,4615,4625,... convention, e.g.) by
|
||||
* searching for a single shift delta that lands every block on free
|
||||
* ground at once, rather than reassigning each block independently. */
|
||||
#define MAX_DELTA_SEARCH 20000
|
||||
static int suggest_renumbering(const int *blocks, int block_count,
|
||||
int skip_idx, int *out_delta) {
|
||||
for (int delta = 0; delta <= MAX_DELTA_SEARCH; delta += 1) {
|
||||
if (blocks_free_at_delta(blocks, block_count, delta, skip_idx)) {
|
||||
*out_delta = delta;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* rewrite_block_headers: in-place rewrite of a capsule's own "Block N"
|
||||
* header lines to "Block N+delta", preserving everything else in the file
|
||||
* byte-for-byte (content, comments, formatting). Returns 1 on success. */
|
||||
static int rewrite_block_headers(const char *fpath, int delta) {
|
||||
FILE *f = fopen(fpath, "rb");
|
||||
if (!f) return 0;
|
||||
fseek(f, 0, SEEK_END);
|
||||
long sz = ftell(f);
|
||||
rewind(f);
|
||||
uint8_t *data = malloc((size_t)sz + 1);
|
||||
if (!data || fread(data, 1, (size_t)sz, f) != (size_t)sz) {
|
||||
free(data); fclose(f); return 0;
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
FILE *out = fopen(fpath, "wb");
|
||||
if (!out) { free(data); return 0; }
|
||||
|
||||
size_t pos = 0;
|
||||
while (pos < (size_t)sz) {
|
||||
size_t start = pos;
|
||||
while (pos < (size_t)sz && data[pos] != '\n') pos++;
|
||||
size_t line_len = pos - start;
|
||||
int has_nl = (pos < (size_t)sz);
|
||||
|
||||
if (line_len >= 7 &&
|
||||
data[start+0]=='B' && data[start+1]=='l' && data[start+2]=='o' &&
|
||||
data[start+3]=='c' && data[start+4]=='k' && data[start+5]==' ') {
|
||||
int bn = (int)strtol((const char *)(data + start + 6), NULL, 10);
|
||||
fprintf(out, "Block %d", bn + delta);
|
||||
} else {
|
||||
fwrite(data + start, 1, line_len, out);
|
||||
}
|
||||
if (has_nl) { fputc('\n', out); pos++; }
|
||||
}
|
||||
fclose(out);
|
||||
free(data);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* write_renumber_patch: permanent, git-tracked decision record -- not a
|
||||
* generic diff (the only thing a renumber ever changes is Block header
|
||||
* integers, so a structured before/after list is more directly useful
|
||||
* than a line diff would be). */
|
||||
static void write_renumber_patch(const char *capsule_name, const int *old_blocks,
|
||||
int block_count, int delta,
|
||||
const char *reason) {
|
||||
mkdir(PATCHES_DIR_PATH, 0755); /* ignore EEXIST */
|
||||
|
||||
time_t now = time(NULL);
|
||||
struct tm *tm_ptr = gmtime(&now);
|
||||
char stamp[32];
|
||||
strftime(stamp, sizeof(stamp), "%Y%m%dT%H%M%SZ", tm_ptr);
|
||||
|
||||
char safe_name[CAPSULE_NAME_MAX];
|
||||
size_t k;
|
||||
for (k = 0; capsule_name[k] && k < sizeof(safe_name) - 1; k++) {
|
||||
char c = capsule_name[k];
|
||||
safe_name[k] = (c == '/' || c == ':') ? '_' : c;
|
||||
}
|
||||
safe_name[k] = '\0';
|
||||
|
||||
char path[MAX_PATH_LEN];
|
||||
snprintf(path, sizeof(path), "%s/%s-%s.patch", PATCHES_DIR_PATH, stamp, safe_name);
|
||||
|
||||
FILE *f = fopen(path, "w");
|
||||
if (!f) {
|
||||
fprintf(stderr, "mkcapsule: WARNING: could not write patch record %s\n", path);
|
||||
return;
|
||||
}
|
||||
fprintf(f, "# mkcapsule --resolve renumber patch\n");
|
||||
fprintf(f, "# capsule: %s\n", capsule_name);
|
||||
fprintf(f, "# timestamp: %s\n", stamp);
|
||||
fprintf(f, "# reason: %s\n", reason);
|
||||
for (int j = 0; j < block_count; j++) {
|
||||
fprintf(f, "%d -> %d\n", old_blocks[j], old_blocks[j] + delta);
|
||||
}
|
||||
fclose(f);
|
||||
fprintf(stderr, "mkcapsule: wrote patch record %s\n", path);
|
||||
}
|
||||
|
||||
/* prompt_yes_no: reads one line from stdin, true only on 'y'/'Y'. */
|
||||
static int prompt_yes_no(const char *question) {
|
||||
fprintf(stderr, "%s [y/N] ", question);
|
||||
char reply[16];
|
||||
if (!fgets(reply, sizeof(reply), stdin)) return 0;
|
||||
return (reply[0] == 'y' || reply[0] == 'Y');
|
||||
}
|
||||
|
||||
/* cmd_resolve: mkcapsule --resolve <dir> -- see main()'s usage text and
|
||||
* FABRIC-3.md SXXIV for the full design. Deliberately the ONLY interactive
|
||||
* mkcapsule mode; the plain build path (check_block_conflicts/
|
||||
* check_reserved_conflicts) stays non-interactive so CI/unattended builds
|
||||
* never block on a prompt. */
|
||||
static int cmd_resolve(const char *dir) {
|
||||
base_dir = dir;
|
||||
base_dir_len = strlen(base_dir);
|
||||
while (base_dir_len > 0 && base_dir[base_dir_len - 1] == '/') base_dir_len--;
|
||||
|
||||
manifest_count = 0;
|
||||
if (nftw(base_dir, manifest_file, 20, FTW_PHYS) != 0) {
|
||||
fprintf(stderr, "mkcapsule: ERROR: directory scan failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
load_reserved_ranges(RESERVED_FILE_PATH);
|
||||
|
||||
int fresh_registry = (access(CLAIMS_FILE_PATH, F_OK) != 0);
|
||||
load_claims_registry(CLAIMS_FILE_PATH);
|
||||
if (fresh_registry) {
|
||||
fprintf(stderr, "mkcapsule --resolve: %s not found, creating fresh "
|
||||
"baseline from current state\n", CLAIMS_FILE_PATH);
|
||||
}
|
||||
|
||||
int registered = 0, updated = 0, resolved = 0, rejected = 0;
|
||||
|
||||
for (int i = 0; i < manifest_count; i++) {
|
||||
ManifestEntry *m = &manifest_entries[i];
|
||||
ClaimEntry *prior = find_claim(m->name);
|
||||
|
||||
if (prior && prior->hash == m->hash &&
|
||||
prior->block_count == m->block_count &&
|
||||
memcmp(prior->blocks, m->blocks,
|
||||
(size_t)m->block_count * sizeof(int)) == 0) {
|
||||
continue; /* unchanged, nothing to do */
|
||||
}
|
||||
|
||||
/* Does this capsule's CURRENT block set collide with anything else
|
||||
* live right now (ground truth = this run's fresh scan), or with
|
||||
* a reserved range? Fresh-registered-or-updated-in-place capsules
|
||||
* whose own new content simply doesn't collide need no prompt --
|
||||
* only an actual collision needs a human decision. */
|
||||
int collides = 0;
|
||||
for (int j = 0; j < m->block_count && !collides; j++) {
|
||||
int lbn = m->blocks[j];
|
||||
if (reserved_owner_of(lbn)) { collides = 1; break; }
|
||||
for (int k = 0; k < manifest_count; k++) {
|
||||
if (k == i) continue;
|
||||
for (int b = 0; b < manifest_entries[k].block_count; b++) {
|
||||
if (manifest_entries[k].blocks[b] == lbn) { collides = 1; break; }
|
||||
}
|
||||
if (collides) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!collides) {
|
||||
ClaimEntry *dest = prior ? prior : &claim_registry[claim_count++];
|
||||
snprintf(dest->name, CAPSULE_NAME_MAX, "%s", m->name);
|
||||
dest->hash = m->hash;
|
||||
dest->block_count = m->block_count;
|
||||
memcpy(dest->blocks, m->blocks, (size_t)m->block_count * sizeof(int));
|
||||
if (prior) { updated++; fprintf(stderr, "mkcapsule --resolve: %s updated (no conflict)\n", m->name); }
|
||||
else { registered++; fprintf(stderr, "mkcapsule --resolve: %s registered\n", m->name); }
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Real conflict -- try to suggest a renumbering. */
|
||||
fprintf(stderr, "\nmkcapsule --resolve: CONFLICT for %s:\n", m->name);
|
||||
for (int j = 0; j < m->block_count; j++) {
|
||||
int lbn = m->blocks[j];
|
||||
const ReservedRange *r = reserved_owner_of(lbn);
|
||||
if (r) {
|
||||
fprintf(stderr, " block %d is reserved: %s\n", lbn, r->desc);
|
||||
continue;
|
||||
}
|
||||
for (int k = 0; k < manifest_count; k++) {
|
||||
if (k == i) continue;
|
||||
for (int b = 0; b < manifest_entries[k].block_count; b++) {
|
||||
if (manifest_entries[k].blocks[b] == lbn) {
|
||||
fprintf(stderr, " block %d also claimed by %s\n",
|
||||
lbn, manifest_entries[k].name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int delta;
|
||||
if (suggest_renumbering(m->blocks, m->block_count, i, &delta)) {
|
||||
fprintf(stderr, " suggested renumbering (shift by %+d):\n", delta);
|
||||
for (int j = 0; j < m->block_count; j++) {
|
||||
fprintf(stderr, " %d -> %d\n", m->blocks[j], m->blocks[j] + delta);
|
||||
}
|
||||
char fpath[MAX_PATH_LEN];
|
||||
snprintf(fpath, sizeof(fpath), "%s/%s", base_dir, m->name);
|
||||
/* capsule name uses ':' for subdirectories; the real path uses '/' */
|
||||
for (char *p = fpath; *p; p++) if (*p == ':') *p = '/';
|
||||
|
||||
if (prompt_yes_no(" Accept this renumbering?")) {
|
||||
if (rewrite_block_headers(fpath, delta)) {
|
||||
char reason[256];
|
||||
snprintf(reason, sizeof(reason),
|
||||
"block conflict, resolved via --resolve");
|
||||
write_renumber_patch(m->name, m->blocks, m->block_count,
|
||||
delta, reason);
|
||||
for (int j = 0; j < m->block_count; j++) m->blocks[j] += delta;
|
||||
ClaimEntry *dest = prior ? prior : &claim_registry[claim_count++];
|
||||
snprintf(dest->name, CAPSULE_NAME_MAX, "%s", m->name);
|
||||
dest->hash = m->hash;
|
||||
dest->block_count = m->block_count;
|
||||
memcpy(dest->blocks, m->blocks, (size_t)m->block_count * sizeof(int));
|
||||
resolved++;
|
||||
} else {
|
||||
fprintf(stderr, " ERROR: could not rewrite %s\n", fpath);
|
||||
rejected++;
|
||||
}
|
||||
} else {
|
||||
fprintf(stderr, " rejected -- %s left unchanged\n", m->name);
|
||||
rejected++;
|
||||
}
|
||||
} else {
|
||||
fprintf(stderr, " no free renumbering found within +%d of any "
|
||||
"existing block -- resolve %s by hand\n",
|
||||
MAX_DELTA_SEARCH, m->name);
|
||||
rejected++;
|
||||
}
|
||||
}
|
||||
|
||||
save_claims_registry(CLAIMS_FILE_PATH);
|
||||
|
||||
fprintf(stderr, "\nmkcapsule --resolve: %d registered, %d updated, "
|
||||
"%d resolved, %d rejected/unresolved\n",
|
||||
registered, updated, resolved, rejected);
|
||||
return rejected > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
/*===========================================================================
|
||||
* C Source Generation
|
||||
*===========================================================================*/
|
||||
@@ -1154,6 +1617,15 @@ int main(int argc, char **argv) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --resolve mode: interactive block-conflict resolver (FABRIC-3.md
|
||||
* SXXIV). The ONLY interactive mkcapsule mode -- deliberately a
|
||||
* separate, deliberate invocation, never part of the plain `make`
|
||||
* build path (that stays non-interactive so CI/unattended builds
|
||||
* never block on a prompt). */
|
||||
if (argc == 3 && strcmp(argv[1], "--resolve") == 0) {
|
||||
return cmd_resolve(argv[2]);
|
||||
}
|
||||
|
||||
/* --lint mode: validate all .4th files without generating C output */
|
||||
if (argc == 3 && strcmp(argv[1], "--lint") == 0) {
|
||||
base_dir = argv[2];
|
||||
@@ -1191,6 +1663,8 @@ int main(int argc, char **argv) {
|
||||
" %s --lint <capsules_dir> lint all .4th files\n"
|
||||
" %s --manifest [--sign-key <keyfile>] <capsules_dir> [<out.md>]\n"
|
||||
" print/write block manifest\n"
|
||||
" %s --resolve <capsules_dir> interactive block-conflict\n"
|
||||
" resolver (see file header)\n"
|
||||
"\n"
|
||||
"Lint checks (per .4th file):\n"
|
||||
" - each block opens with 'Block N'\n"
|
||||
@@ -1206,7 +1680,7 @@ int main(int argc, char **argv) {
|
||||
"\n"
|
||||
"Names are colon-separated relative paths (e.g. core:init.4th).\n"
|
||||
"Files whose encoded name exceeds %d bytes are skipped with a warning.\n",
|
||||
argv[0], argv[0], argv[0], CAPSULE_NAME_MAX - 1);
|
||||
argv[0], argv[0], argv[0], argv[0], CAPSULE_NAME_MAX - 1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1224,6 +1698,9 @@ int main(int argc, char **argv) {
|
||||
if (check_block_conflicts() != 0) {
|
||||
return 1;
|
||||
}
|
||||
if (check_reserved_conflicts() != 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
fprintf(stderr, "mkcapsule: scanning %s\n", base_dir);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user