Skip to content

Executive-resident process table: a VMS process name survives exec and is visible to other processes (vms-8019) - #12

Merged
baron-3dl merged 7 commits into
mainfrom
work/vms-8019
Jul 30, 2026
Merged

baron-3dl merged 7 commits into
mainfrom
work/vms-8019

Conversation

@baron-3dl

Copy link
Copy Markdown
Contributor

Replaces the first of the three known Rule 11 facades with a real executive facility. Partial by design — see What this does not do.

The bug

src/vmsprocess/vms_pcb.c kept the PCB in process-local memory, so sys$creprc's prcnam was discarded at exec and a VMS process name was unobservable from anywhere. Nothing could look a process up by name.

A previous attempt bridged the name across exec in a VMS_PRCNAM environment variable, following the existing VMS_USERNAME/VMS_TERMINAL convention. It compiled, tested green, and was a cheat — a process self-reporting a name nothing else can see. It was rejected.

The actual fix

The name never needed a carrier. execve() does not change the Linux pid, and vms.ko already keys struct vms_proc by current->pid — so identity survives exec via a lifetime change in vms_dev_release() (free only on PF_EXITING, plus lazy reaping), not via userspace state. New src/kernel/vms_proctab.c plus its ioctl surface and client.

Proven A-writes / B-reads (Rule 11's decisive test) against a real /dev/vms in QEMU: 30 passed / 0 failed, 11 suites, with a negative control and cross-process resolution of a pid the reader never supplied.

Oracle pinning — the tree was wrong in more places than suspected

ssdef.h:74 claimed SS$_POWERFAIL 598 /* VMS: 0x254; 596 taken by SS$_IVLOGNAM */. Both halves were wrong. Pinned on the live VAX 7.3 lab by two independent documented-tool methods ($SSDEF extract + SEARCH, and F$MESSAGE round-trip) plus behavioural probes:

SS$_DUPLNAM 148 · SS$_IVLOGNAM 340 · SS$_NOIOCHAN 434 · SS$_VOLINV 596 · SS$_POWERFAIL 868 · SS$_NONEXPR 2280 · SS$_RIGHTSFULL 2540 · SS$_IVSECFLG 364 · SS$_UNASEFC 564 · SS$_SYNCH 1673

Correcting those surfaced three value collisions in the header, all resolved. grep | sort -n | uniq -d now reports only the intentional SS$_NORMAL == SS$_CONTINUE.

The worse bug found while fixing a lesser one

The client truncated an oversized process name in userspace, so the executive's validator never saw it. Fixing that revealed the $GETJPI lookup key was clipped too — an overlong name resolved the process holding its first 15 characters, i.e. answered for a different process. The key now has its own sel_prcnam field and info.prcnam is output-only. Oracle boundary: 15 chars accepted, 16 rejected with SS$_IVLOGNAM; VMS neither truncates nor partially applies.

Verification

Every claim proven by execution, and independently re-derived by an adversarial reviewer from a clean git archive rather than from the report:

  • Mutation-proven discrimination. UIC-group scoping is exercised by a setgid(300)-while-uid 0 helper. Deleting the group check flips exactly the three discriminators (30/0 → 25/5). Restoring userspace truncation flips exactly the five length assertions.
  • Negative control kept honest. The new suite makes the harness emit 3 passed / 8 failed; ci.yml's pinned literal moved 7 → 8, with a comment recording that the brittleness is the detector and must never be softened into a range.
  • Host ctest 40/40, 1 known skip. No test deleted, skipped or weakened; 8 assertions added.

What this does not do

sys$getjpi, dcl_cmd_show.c and sys$creprc are not yet readers of this table, so SHOW SYSTEM still prints only the caller. That waits on vms-9fc wiring vms_kif_register() — today it has zero callers, so converting them would need either a forbidden per-process fallback or would break the host suite where /dev/vms does not exist. vms-8019 stays open.

⚠️ ABI: the process-table ioctl struct sizes and numbers changed. vms.ko and userspace must be rebuilt together. The interface is new on this branch and has no released consumers. Struct sizes and ioctl encodings are now locked by _Static_assert with a wrong-literal negative control — the substitute for an x86_64 runtime proof, which this host cannot run (vms-d4b records the gap and its human prerequisite).

🤖 Generated with Claude Code

baron-3dl and others added 7 commits July 30, 2026 08:42
…v/vms

Adds a shared process table owned by vms.ko so a VMS process name is
observable from OTHER processes, replacing nothing yet -- the userspace
consumers (sys$getjpi, dcl_cmd_show.c, sys$creprc) are deliberately NOT
converted to readers, because vms_kif_register() still has zero callers
(vms-9fc) and converting them today would either need a per-process
fallback (forbidden, Rule 11) or break the host ctest suite where
/dev/vms does not exist.

RESCUED COMMIT: the implementing agent's sandbox denied all git commands,
so this was committed by the orchestrator from the agent's worktree.

NOT PROVEN: the A-writes/B-reads QEMU gate has never been executed -- the
agent was also denied qemu-system-aarch64 and ctest. vms.ko is UNBUILT,
so the three new vms_module.c hunks are not even compile-checked. This
branch is partial work published for continuation, not a merge candidate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The executive process table returned SS$_DUPLNAM 434, SS$_IVLOGNAM 596
and SS$_NONEXPR 2540. All three were wrong, and the tree already carried
the tell: ssdef.h justified SS$_POWERFAIL 598 with "596 taken by
SS$_IVLOGNAM" while noting VMS puts POWERFAIL at 0x254 -- which IS 596.
At least one of the two had to be wrong; both were.

Pinned on the reference lab, OpenVMS VAX V7.3 node VAX1, 2026-07-30, by
two independent documented-tool observations:

  $ LIBRARY/EXTRACT=$SSDEF/OUTPUT=SYS$SCRATCH:SSDEF.MAR -
        SYS$LIBRARY:STARLET.MLB
  $ SEARCH SYS$SCRATCH:SSDEF.MAR "IVLOGNAM","DUPLNAM","NONEXPR",-
        "VOLINV","POWERFAIL"
    $EQU    SS$_DUPLNAM     148
    $EQU    SS$_IVLOGNAM    340
    $EQU    SS$_VOLINV      596
    $EQU    SS$_POWERFAIL   868
    $EQU    SS$_NONEXPR     2280

  $ WRITE SYS$OUTPUT F$MESSAGE(148)   %SYSTEM-F-DUPLNAM,  duplicate name
  $ WRITE SYS$OUTPUT F$MESSAGE(340)   %SYSTEM-F-IVLOGNAM, invalid logical name
  $ WRITE SYS$OUTPUT F$MESSAGE(434)   %SYSTEM-E-NOIOCHAN, no I/O channel available
  $ WRITE SYS$OUTPUT F$MESSAGE(596)   %SYSTEM-F-VOLINV,   volume is not software enabled
  $ WRITE SYS$OUTPUT F$MESSAGE(868)   %SYSTEM-F-POWERFAIL, power failure occurred
  $ WRITE SYS$OUTPUT F$MESSAGE(2280)  %SYSTEM-W-NONEXPR,  nonexistent process
  $ WRITE SYS$OUTPUT F$MESSAGE(2540)  %SYSTEM-F-RIGHTSFULL, rights list is full

So 596 is VOLINV, not IVLOGNAM; 598 is VOLINV re-severitied rather than
a distinct condition; and the displacement note was built on sand.

The CHOICE of SS$_IVLOGNAM for a malformed process name -- which reads
oddly, being named for logical names -- is oracle-pinned too, but
behaviourally rather than by symbol:

  $ SET PROCESS/NAME="THISNAMEISWAYTOOLONG"
  %SET-E-NOTSET, error modifying process name
  -SYSTEM-F-IVLOGNAM, invalid logical name

Severities in the DCL message table are corrected from the same
observations (DUPLNAM and IVLOGNAM are F, NONEXPR is W; the 596 slot was
mislabelled IVLOGNAM and is now VOLINV).

Scope: only the constants this facility returns, plus SS$_POWERFAIL,
whose sole documented rationale was the now-retired 596 collision. The
rest of ssdef.h remains unpinned -- that is vms-c90.

vms_internal.h also gains the vms_proc_free_claimed() declaration used
by the reaper fix later on this branch; it is inert here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vms_proctab.c asserts in code and in three comments that process-name
uniqueness AND lookup are scoped to the UIC group, but every process in
test_kmod_procnam ran as root, so uic_group was 0 everywhere and the
group comparison was trivially true. The test printed an identical 15/15
with the comparison deleted outright: the one piece of VMS fidelity the
file argues hardest for had zero discriminating coverage. That is the
same shape as the facade class this item exists to kill -- correct-
looking output no test can tell from the wrong implementation.

The scoping itself is now oracle-pinned, on the reference lab OpenVMS
VAX V7.3 node VAX1, 2026-07-30, with three detached processes taking the
SAME process name under different UICs:

  $ RUN/DETACHED/UIC=[300,1]/PROCESS_NAME=OVMXDUP ... LOGINOUT.EXE
  %RUN-S-PROC_ID, identification of created process is 20200220
  $ RUN/DETACHED/UIC=[301,1]/PROCESS_NAME=OVMXDUP ... LOGINOUT.EXE
  %RUN-S-PROC_ID, identification of created process is 20200221
  $ RUN/DETACHED/UIC=[300,2]/PROCESS_NAME=OVMXDUP ... LOGINOUT.EXE
  %RUN-F-CREPRC, process creation failed
  -SYSTEM-F-DUPLNAM, duplicate name

Same name, different group: accepted. Same name, same group: refused
with SS$_DUPLNAM. (Both survivors were confirmed alive in SHOW SYSTEM
first, so the acceptance is not an artifact of the first one exiting.)

The test now forks a helper that setgid()s to group 300 before
registering -- staying uid 0, so it keeps the privilege to open
/dev/vms, but landing in a different UIC group, which the executive
derives from its credentials rather than taking on its word. Three
assertions discriminate:

  A  a name group 0 holds is FREE in group 300     (group-blind: DUPLNAM)
  B  the helper resolves that shared name to ITSELF, not to group 0's row
  C  from group 0, a name held only in group 300 does NOT resolve
                                                  (group-blind: SS$_NORMAL)

Also locks the process-table ioctl ABI with _Static_asserts on struct
sizes and on the encoded ioctl numbers. The kernel side of vms_ioctl.h
takes _IOWR from <linux/ioctl.h> while vms_kif.c gets the hand-rolled
fallback at the top of the header (it includes no <sys/ioctl.h>), and
the QEMU proof has only ever been RUN on aarch64 -- the x86_64 half of
that agreement was an assumption. The assertions are evaluated by every
translation unit that includes the header, so the CI x86_64 build proves
the layout on an architecture this host cannot execute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vms_proc_reap_dead() selected a victim under vms_proc_hash_lock, dropped
the lock, then called vms_proc_free(victim) -- which reads
victim->hash_node to claim the entry. In that gap a concurrent
vms_dev_release() of an exiting task could claim the same entry and
kfree_rcu() it, so the claim's own hlist_unhashed() guard was read from
potentially freed memory. Narrow (reaping only touches entries whose
task is already released) but real, and -smp 1 under QEMU cannot surface
it, so no test would ever have caught it.

The unlink IS the ownership claim, so it must happen while the lock is
still held. vms_proc_free() is split: vms_proc_free_claimed() does the
teardown of an entry the caller has already unlinked, and
vms_proc_free() stays the claim-then-tear-down entry point for callers
that still hold nothing. The reaper hash_del_rcu()s inside its own
locked traversal and then calls the claimed variant, so no other path
can be mid-free on an entry it is looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The kernel-executive-negative-control job asserts the exact split
init.sh reports when /dev/vms is absent. tests/qemu/init.sh
auto-discovers /tests/test_kmod_*, so adding test_kmod_procnam made an
11th suite that correctly fails without the executive -- the harness now
reports "3 suites passed, 8 suites failed" and the pinned literal 7 sent
the job red on every push.

Proven by execution here, not by reading:
  podman build -f tests/qemu/Dockerfile --build-arg NEGATIVE_CONTROL=1
  podman run --rm <image>
  => "=== FINAL RESULTS: 3 suites passed, 8 suites failed ==="
and the three greps this job runs -- 'cannot open /dev/vms', the FINAL
RESULTS literal, and 'PASS: vmsfs.ko loaded, filesystem registered' --
all match on that output with 8 and none match with 7.

The comment now states the maintenance convention this literal encodes,
because the obvious "fix" is to relax it into a range or a regex: an
executive-dependent suite that silently stopped depending on /dev/vms is
exactly what this job exists to catch, so the literal has to be edited
by hand every time the suite list changes. The brittleness is the
feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pinning SS$_POWERFAIL 868, SS$_NONEXPR 2280 and SS$_UNASEFC 564 landed
each of them on top of a placeholder that already held that value, so
the source-of-truth header ended up asserting that two distinct VMS
conditions share one status:

  SS$_POWERFAIL 868  ==  SS$_SYNCH 868
  SS$_NONEXPR   2280 ==  SS$_UNASEFC 2280
  SS$_UNASEFC   564  ==  SS$_IVSECFLG 564   (created by fixing the second)

A header that permits this is not merely untidy: `status == SS$_SYNCH`
would also have matched SS$_POWERFAIL. This is the pinning commit's own
blast radius, not the vms-c90 constants sweep.

Every displaced symbol is pinned on the same oracle by the same two
independent documented-tool methods (reference lab node VAX1, OpenVMS
VAX V7.3, 2026-07-30), driven and read back by me rather than taken from
a report:

  LIBRARY/EXTRACT=$SSDEF ... SYS$LIBRARY:STARLET.MLB + SEARCH
      $EQU  SS$_IVSECFLG   364
      $EQU  SS$_UNASEFC    564
      $EQU  SS$_SYNCH      1673
  F$MESSAGE(364)  -> %SYSTEM-F-IVSECFLG, invalid process or global section flags
  F$MESSAGE(564)  -> %SYSTEM-F-UNASEFC,  unassociated event flag cluster
  F$MESSAGE(1673) -> %SYSTEM-S-SYNCH,    synchronous successful completion

SS$_SYNCH at 1673 is still a success status (1673 & 7 == 1), so it stays
in the success section on its own merits rather than by placement.

The only remaining duplicate value in the header is the intentional
SS$_NORMAL == SS$_CONTINUE == 1. Checked mechanically, not by eye:
  grep -oP '^#define\s+SS\$_\w+\s+\K\d+' ssdef.h | sort -n | uniq -d
prints exactly "1".

The section headings are now noted as unreliable severity labels -- some
pinned values sit under a heading their real severity contradicts.
Reconciling the headings and the remaining unpinned constants stays
vms-c90; this commit only removes the contradictions it introduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ncate it

The userspace client copied an inbound process name into a
VMS_PRCNAM_SIZE field before handing it to /dev/vms, so the executive
never saw an oversized name at all. vms_kif_setprn("THISNAMEISWAYTOOLONG")
returned SS$_NORMAL and the executive then held "THISNAMEISWAYTO" -- a
process silently named something the caller never asked for. The
$GETJPI name selector had the same clip and was worse: an oversized
lookup key resolved whatever process holds its first 15 characters, so
the call answered for a DIFFERENT process instead of refusing.

That is the illegal third answer: a plausible-looking handler (truncate
and succeed) for a condition VMS refuses outright. The oracle
transcript saying so was already quoted in this tree while the code did
the opposite of what it quoted.

Oracle, driven and read back here (VAX1, OpenVMS VAX V7.3, 2026-07-30):

  $ SET PROCESS/NAME="IMPL8019NAM15X"      ! 14 chars -> accepted
  $ SET PROCESS/NAME="IMPL8019NAM15XY"     ! 15 chars -> accepted
  $ SET PROCESS/NAME="IMPL8019NAM15XYZ"    ! 16 chars
  %SET-E-NOTSET, error modifying process name
  -SYSTEM-F-IVLOGNAM, invalid logical name
  $ WRITE SYS$OUTPUT F$GETJPI("","PRCNAM")
  IMPL8019NAM15XY                          ! UNCHANGED

So the boundary is exactly 15, the answer is SS$_IVLOGNAM (340, pinned),
and the existing name is left alone -- VMS does not truncate and does
not partially apply.

An inbound name now travels in VMS_PRCNAM_XFER (64), a buffer strictly
larger than the longest legal name, so an oversized name arrives intact
and name_is_valid() -- which inspects only the first VMS_PRCNAM_SIZE
bytes -- rejects it. The $GETJPI key moves out of info.prcnam into its
own sel_prcnam field, making info.prcnam output-only; a row's name is
never also the lookup key. The userspace copy is still bounded, but the
bound cannot manufacture a legal name, and a _Static_assert holds
VMS_PRCNAM_XFER > VMS_PRCNAM_SIZE so it never can. This is an OVMX wire
choice, labelled as one in the header -- VMS publishes no ioctl.

The struct sizes and therefore the ioctl numbers change (SETPRN
0xC0185641 -> 0xC0485641, GETJPI 0xC0305642 -> 0xC0705642). The ABI
_Static_asserts move with them; verified by compiling the header
natively and printing the values, which match the new literals.

Test section 8 is what tells the two implementations apart, using a
16-character name whose first 15 characters are the name the process
already holds -- so a truncating client returns SS$_NORMAL for it (no
clash with anybody) while the executive returns SS$_IVLOGNAM. It also
asserts the name is unchanged after each rejection, that a 200+
character name past the transfer buffer is rejected too, that an
oversized lookup key does not resolve the holder of its prefix, and, as
a control, that the legal 15-character key still resolves.

Ran in the QEMU harness against a real /dev/vms on aarch64:
  test_kmod_procnam: 30 passed, 0 failed   (was 22)
  FINAL RESULTS: 11 suites passed, 0 suites failed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@baron-3dl
baron-3dl merged commit 00f426c into main Jul 30, 2026
34 checks passed
baron-3dl added a commit that referenced this pull request Jul 30, 2026
Third rebase of PR #6. main advanced to 00f426c (vms-8019, "Executive-resident
process table", PR #12).

src/libvmssys/vms_kif.c auto-merged clean: vms-8019's new SETPRN/GETJPI/PROCSCAN
wrappers carry no vms_dev_fd absence guard, so nothing this branch deletes came
back.

The one conflict is the kernel-executive negative-control literal in ci.yml, and
the textual merge of it was WRONG in a way that would have silently weakened the
control. Both branches independently moved the executive-absent split from
"3 passed, 7 failed" to "3 passed, 8 failed" -- main by adding
test_kmod_procnam, this branch by adding test_kmod_pin, both executive-
dependent. Merged, BOTH suites exist, so the real split is 3 passed / 9 failed.
Taking either side verbatim would have left a stale 8 that no longer describes
reality; a stale count here is exactly the silent weakening the control exists
to prevent. Resolved to 9, and the comment now records the arithmetic and the
per-item history so the next merge sees the trap coming.
baron-3dl added a commit that referenced this pull request Aug 7, 2026
Merging with four inherited failures, each verified red on main's own run 31140591441 at the SHA this branch forked from. Nothing green on main is red here.

Build & Test comparison (main job 92749473589 vs PR job 92907044517):
  main: 2 failures -- #12 userspace_service_register_negctl (852.01s) AND #24 x86_64_reloc_survey_fresh
  PR:   1 failure  -- #24 x86_64_reloc_survey_fresh only; #12 now Passed (792.97s)

ctest #12 is HEALED by vms-0c3. ctest #24 (labelled 'integration toolchain') is pre-existing and in the same family as the three IMGACT/link job failures -- another session's area, untouched by this batch.

Kernel Executive Per-Facility Negative Controls: all 6 shards green here; shard 0 is RED on main (bind-client-no-register, test_syssvc_setuai undeclared). Healed by vms-570.

Inherited-red jobs: OVMX_IMGACT Build Mode (musl), OVMX_IMGACT Build + Activate (musl, x86_64), Multi-Object main() Executable Link + Activate x86_64 (vms-206), and Build & Test via ctest #24.
baron-3dl added a commit that referenced this pull request Aug 7, 2026
…negctl fixture, attribution race) (#164)

Four tests were red on main. Three were deterministic reds hidden behind
"flaky"/unowned labels; one was a genuine concurrency race. Full suite is
113/113 green under `ctest -j` after these fixes.

x86_64_reloc_survey_fresh (#24): docs/design-link-x86_64-relocs.md went stale
after sys_uai.c pulled in vms_kif.h. The survey's include list is built from
src/<c>/include per component, and src/libvmssys has no include/ subdir, so 8
libvmssys-dependent TUs failed to compile standalone and silently dropped from
the surveyed set. Add -I$REPO/src/libvmssys and regenerate (72obj/13fail ->
80obj/5fail; remaining 5 are unrelated missing-header cases). awk/tooling logic
otherwise unchanged.

userspace_service_register (#11): the gate walked $SRC_ROOT with a raw `find`,
descending into gitignored .claude/worktrees/ (parallel repo checkouts) and
reading their add_subdirectory()/install() as this tree's, flagging escapes that
belong to stale checkouts. Scope all three CMakeLists.txt walks to git-tracked
files via `git ls-files`, with a .claude-pruned `find` fallback for non-git
trees. "The product tree" == tracked source; not a silent skip.

userspace_service_register_negctl (#12): two stacked bugs in the
register_buildset.awk PARTIAL-parse control, deterministically red (identical on
a pristine HEAD worktree), not flaky. (a) The malformed fixture did `sed '$d'`,
deleting the trailing array ']' instead of the entry's '}', so the parser's
"file"-vs-"}" balance stayed even and never tripped. (b) Once (a) was fixed the
awk correctly refused with its diagnostic on stderr (by design; the real gate
splits stdout/stderr), but the control captured stdout only, so its "named
reason" grep saw an empty string. Fix both; register_buildset.awk is unchanged.

facility_attribution_negctl (#111): section G derived the host site list by
running the instrument against the LIVE shared checkout, then diffed it against
the container side — a snapshot frozen into the image at build. A concurrent
write to the checkout mid-run (another test or another session) skewed the diff,
producing a different spurious mismatch every run (the "different symptom every
run" flaky signature). Run the frozen $WORK/pristine snapshot on both sides so a
real image/tree divergence still reds but a mid-run edit cannot. No test
weakened, no retries/sleeps, committed execution record untouched.

Closes vms-354, vms-53f, vms-60c, vms-2d4; completes vms-aa6.

Co-authored-by: alice <alice@workspace.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
baron-3dl pushed a commit that referenced this pull request Aug 8, 2026
…k check

The vms-b5b harness fix added, to all 30 tests/qemu/*.c suites:

    setvbuf(stdout, NULL, _IOLBF, 0);  /* ... unflushed fork() cannot splice ... */

The literal "fork()" in that comment collides with a gate on CURRENT main:
tests/integration/test_userspace_service_register.sh line 1621 detects a
single-process proof with `grep -q 'fork[[:space:]]*(' "$proof"` on RAW,
un-comment-stripped source (unlike the calls-check at 1634, which runs
strip.awk first). The negctl control at
test_userspace_service_register_negctl.sh:681 redirects the event-flag
services' proof to test_syssvc_ef_local.c (a single-process suite) and
expects "EXECUTIVE DECLARATION WHOSE PROOF IS SINGLE-PROCESS: sys$setef".
Our comment made ef_local.c match the fork grep, so the SINGLE-PROCESS check
was skipped and the gate fell through to "NEVER CALLS THE SERVICE:
sys$ascefc/dacefc/dlcefc" -- ctest #12 userspace_service_register_negctl
went red (green on main, deterministic across two runs).

Reword the comment to say "child process" so it carries no `fork(` token.
setvbuf behavior is unchanged; this restores the gate's fork check to
working correctly (closing the comment-spoof hole the batch opened) without
touching the gate. NOTE for authenticity owner: the fork check greps raw
source and is comment-spoofable in general -- it should strip comments like
the calls-check does (follow-up, out of this batch's scope).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
baron-3dl added a commit that referenced this pull request Sep 4, 2026
…fect:

SCS hands a SYSAP its 132-byte BODY, the CM codec parsed a 204-byte FRAME

THE WALL, decoded from the live run rather than guessed. On join-e72refire
(2026-09-04, the 2-node VAX cluster) this node's promotion burst reached the
wire and **VAX2 answered 0.7 ms later with the cat-0x01 op-0x03 membership
COMMIT that starts admission** (pcap frame 271, t=18.6853, txn=0005 cks=1475
epoch=7 role=0x20 class=0x02). OVMX never answered it. The CNXTRACE ring says
exactly why, in one record: `ARRIVAL state=ADMIT detail=unparsed aux=0x00000084`
-- 0x84 = 132, the SYSAP body length. Three real inbound CM messages were lost
that way in that one run: VAX1's op-0x01 parameters (t=16.371), VAX2's op-0x01
(t=18.683) and VAX2's op-0x03 COMMIT.

Design sec 3.2.4 is explicit about the receive seam: SCS "calls
scs_sysap_ops.message(ctx, local_conid, frame + 72, inner_len - 16)", and
vms_scs_fsm.c's h_rx_appl_msg does exactly that -- "its own 132 bytes and
nothing below them". FC-P3.15's body-level retrofit converted the
ORIGINATE/RESPOND half and left the PARSE half calling vms_frame_classify(),
which reads the ETHERTYPE at abs 12. A SYSAP never sees one, so every real
inbound CM message was refused as E_NOTSCA. Every host test stayed green
because they fed composed 204-byte frames: they were testing a contract the
executive never uses.

THE FIX, in three parts, all of them consequences of that one boundary:

1. RECEIVE IS BODY-LEVEL. Every parse and every response recipe in
   vms_cluster_codec_cm.{c,h} takes the 132-byte body through the VMS_OFB_CM_*
   family (the VMS_OFF_CM_* absolute family stays, derived, for the test-only
   composer). cm_check_class(fi) becomes cm_check_body(body, len) -- an exact
   length gate, because spec sec 4(d) makes the VMS$VAXcluster class fixed.
   cnxman_{join,barrier,coord}_rx_frame() become _rx_body().

2. A PARTICIPANT ADDRESSES BY CSB, NOT BY CSID. cnxman_ops_send() resolves a
   destination CSID, and vms_cnxman.c's own comment already said "no CSID is
   ever learned, so every CSID-addressed origination honestly fails here" --
   so the barrier could not send one of its twelve op-0x0b steps, and could
   not even acknowledge a transition open. A peer's CSID is not something this
   node can learn (E30: op-0x06 carries "a member re-asserting ITS OWN record
   or another already-admitted member's"). The CONNECTION is real: book p. 7-23
   makes a CSB the record of the SCS connection to that system. New
   cnxman_ops.send_csb(csb_index, ...) + `from_csb` through the rx entries;
   the barrier keeps `coordinator_csid` as identity and `coordinator_csb` as
   address.

3. THE IDENTITY EXCHANGE IS PER-PEER (part A). Decoded from the reference join
   vax3-2to3-established-join-20260730: the joiner sent op-0x14+op-0x01 to VAX1
   at t+29.8253 AND to VAX2 at t+30.3692, each on that peer's own VC with its
   own send-msg# starting at 1, and both members sent theirs back the same way
   -- it is what a connection manager says on every VMS$VAXcluster connection,
   in both directions. Only op-0x02 is single-coordinator. So it moves out of
   the join's step 5 into cnxman_join_advertise_peers(), run on CNXMAN's
   once-a-second beat over every CSB the ladder calls OPEN, idempotent per
   (peer, connection) via a new CSB `cm_advert_conid`/`cm_advert_sent` pair.
   That covers the member whose connection arrived while the join was in IDLE
   -- the VAX1 case, i.e. the node CLUSTER_NODES is read from. Its mirror is
   fixed too: a peer's PARAMS is now filed in the SENDER's CSB (it used to go
   to the join's target), in any state, and vms_cnxman.c records the ack side
   once per message on the CSB it really arrived on.

FOUND AND FIXED ALONG THE WAY: a duplicate send-msg# at the exact hand-over
from the join's dialogue to the barrier's. The join advanced the CSB counter
before stamping (correct: first message carries 1); the barrier/coordinator
stamped and let the glue advance after the send. They share a CSB -- the
member a join is driven through is the coordinator whose transition the
barrier answers -- so the first barrier-side body repeated the join's last
number, on the one connection that matters. All originations now go through
cnxman_envelope_originate() (assign, then stamp) and the transport thunks
advance nothing. The R1 test asserts strict monotonicity across the whole
drive and REDDENS when the fix is reverted (verified).

INV-6: nothing here manufactures membership. The only route to MEMBER remains
a real op-0x06 carrying a shape-valid coordinator CSID, from which the
generation is read off the wire and this node's own CSID computed from its own
real SCSSYSTEMID. test_member_only_on_a_real_op06_csid drives the FULL
completion -- COMMIT, rebuilds, membership burst, transition open, GO, twelve
barrier steps, release #12 -- with an op-0x06 carrying no valid CSID and
asserts the barrier completes (an unanswered obligation strands the
coordinator) AND the local CSID is still unlearned. The per-peer beat is
asserted over 200 runs to send four records and touch no membership cell.

TESTS: 60/60 cluster + 236/236 full + 109/109 unit; test_cnxman_join 388
checks (was 340). New: the seam assertion (a BODY parses and is echoed; a
whole FRAME is honestly NOT_MINE), the post-ADMIT drive to MEMBER op by op,
the MEMBER-only-on-a-real-CSID negative, and four per-peer tests. Gate OK (73
files). NetBSD-VAX elf32 ALL PROOFS PASSED, Linux vms.ko clean.

KNOWN TWIN, NAMED NOT HIDDEN: vms_mscp_cl_fsm.c still classifies a frame on a
path SCS feeds a body, so an MSCP END is refused and counted. It cannot block
a join (the disk client is not a membership prerequisite, E68) and converting
that codec touches the MSCP server and block-transfer paths -- its own item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
baron-3dl added a commit that referenced this pull request Sep 4, 2026
… CREDIT

dialogue on a coalescing quantum of 3, and MEMBER waits for op-0c #12

E78's credit fix worked: against the live cluster the coordinator drove the
op-0x06 MEMBERSHIP burst for the first time ever. OVMX answered EVERY record
with a cat-0x04 — 254 frames in 31.6 ms, ~8000/s — and VAX2 took a fatal
INVEXCEPTN on the interrupt stack and halted. It also reached its own MEMBER
state off the CSID in those records, before a single barrier frame existed.

THE LAW, MEASURED (vax3-2to3-established-join-20260730, joiner->coordinator):

    254 op-0x06 in  ->  84 cat-0x04 out.   Ratio 3.02.

with two independent readings of WHY that agree exactly: the ack-msg word at
body[2:4] advances by 3 across 83 of 83 in-burst gaps (never 1, never 2), and
the credit field at abs 62 reads 3 on 85 of the 86 carriers on that link.
Corroborated corpus-wide by the parallel crash-safety audit at 6549 acks from
six responder nodes, advance >= 3 in every one.

So the cat-0x04 is a CREDIT CARRIER on a COALESCING QUANTUM of three released
buffers — one credit is one released buffer is one consumed peer message, so
the credit stamped and the ack advance are one fact seen twice. Both wrong
answers are now excluded by an equality assertion, not a bound: one per record
(254 frames, the crash) and one per exhausted credit WINDOW (~26, an under-ack
by a factor of the grant, which passes any "fewer than N" test).

The three opcodes sec 4(o) lists (04/49, 04/00, 04/02) are not three messages
and not a phase set: one message from a rotating pool of three recycled
buffers (28/34/28 across the burst), body[9] being uninitialised content per
sec 4(p) — the 0x49 frame reads "\x04\x49IR_LOOKUP  SCS$DIRECTORY". We send
zeros.

WHAT CHANGED

* join_h_membership() CONSUMES: counts the record, learns the CSID, originates
  nothing. No cat-0x04 is built anywhere in that FSM any more.
* scs_fsm_credit_return_due() — new, SCS-owned: has this CDT's ledger reached
  SCS_CREDIT_COALESCE. The decision is a ledger read; a SYSAP cannot talk it
  into a carrier it has no credit for.
* cnxman_credit_carrier() emits the grounded cat-0x04 on the VC, after the
  FSMs (so an answer that already piggybacked leaves nothing to carry) and
  envelope-stamped from the CSB, which is what makes the ack advance real. It
  is a cat-0x04 and not the op-8 special credit message because the reference
  emits 0 op-8 on the 204-byte VC class; op-8 stays the backstop.
* The p. 2-44 low-credit flush DEFERS for the length of a SYSAP delivery
  (scs_fsm.delivery_depth) and is re-tested at its end — p. 2-44 is "instead of
  waiting for a message to ride on", and inside a delivery there may still be
  one. The teardown's deliberate op-8 does NOT defer (deferring it wedged the
  directory disconnect behind a timer; caught by test_scs_directory).
* MEMBER now turns over ONLY on the barrier's real op-0x0c #12
  (cnxman_barrier.commits, moved in barrier_finish and nowhere else), routed as
  CNXMAN_EV_TRANSITION_DONE. Blocked if this node has no learned CSID (E73's
  assertion, kept) or if the coordinator's nodemap NAMED us and said no.
  Nodemap SILENCE promotes and is counted (commits_unmapped): sec 4(p) says the
  bitmap's width is undetermined, so treating silence as refusal would strand a
  real join. Phase 2 now reports that answer three-valued.

TESTS — the new ones red on either wrong answer, not just on the flood
* test_cnxman_op06_flood.c (new): 255 records -> EXACTLY 85 carriers; no
  carrier stamps below the quantum; the credit returned is quantum*carriers
  (which finally explains sec 4(h)(1c)'s "last credit never returned", 131/131);
  the deferral isolated; source scans that the shipping code is built this way.
* test_cnxman_join: 255 op-0x06 -> ZERO originations and still ADMIT; MEMBER
  asserted absent after each of releases 1..11 and present only on #12.
* Corrected the tests that encoded the refuted premise (acks_sent==1,
  CSID_LEARNED->MEMBER). They now assert the measured behaviour.

VERIFIED: 63/63 cluster_host+cluster_sim, 112/112 -L unit, 239/239 full ctest,
cluster_core_includes_gate OK, elf32-vax cross-compile ALL PROOFS PASSED
(vms_scs_fsm/vms_cnxman/vms_cnxman_join_fsm/vms_cnxman_barrier_fsm all ILP32
width-clean). No lab build, no boot, no fabricated membership.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
baron-3dl added a commit that referenced this pull request Sep 4, 2026
…tum 3 (SCS_CREDIT_COALESCE=3, census-grounded); no flood; MEMBER gated on real barrier op-0c #12; ordering bug fixed. + CRASH-SAFETY GATE merged (9-class taxonomy, 0 FP on 19 ref captures, flags all 3 known crashes, found N2/N3/N5). 63/63+full 239/239+elf32-vax. Next: reset+re-fire (run the gate on the pcap)
baron-3dl added a commit that referenced this pull request Sep 5, 2026
…DINATOR; both the crash and the stall are one defect (a mandatory field asserted as zero)

REFUTES all three E85 hypotheses. The seq350 close is neither (a) the 17.4s
channel-listen-timeout, nor (b) a peer DISCONNECT, nor (c) OVMX going idle at
the barrier. The pcap says OVMX DID drive its own barrier step 1 (frame 888,
op-0x0b, epoch 5, step 1) and the coordinator DID acknowledge it (frame 890,
0x81/0x0b, 1.2 ms later) -- the drive was never the problem. What happened is
frame 898, at t+23.9535: `msgtype 0xb1` from `08:00:2b:1e:85:61` ("VAX2  ") to
the cluster multicast -- the sec 4(O.30) LAST GASP. The coordinator BUGCHECKED
0.6 ms after OVMX's frame 894, and the station then vanished from the LAN for
~90 s (per-5s per-MAC census: present 0-25 s, absent 25-115 s). CNXTRACE's
`cdt-closed rc=5 (path lost)` at seq350 is OVMX correctly observing a dead
peer. There was no barrier to be idle in: the cluster was already gone.

E83's vc_failover did not fire because ITS PRECONDITION DID NOT HOLD, not
because it is broken: no channel listen-timeout occurred (VAX2 was transmitting
2 ms earlier), and VAX2 presented on exactly ONE channel, so there was no
alternate path to move a circuit to. `vms_pe_fsm.c` is untouched by this
change; the "3 MACs" in the E83 ledger are VAX1's and VAX2's across the run,
not concurrent paths for the dying system.

THE FRAME: 894, `cat-0x86 op-0x00`, the transaction-close RESPONSE, the FIRST
one OVMX ever emitted (254 op-0x06, 85 cat-04 acks, one close). It is the only
OVMX SYSAP frame between the barrier ack and the gasp; the step before it was
acknowledged, i.e. accepted. Spec sec 4(p) already names this response as the
one whose payload bugchecks the peer -- it named the ECHO variant; this is the
other one.

WHAT WAS WRONG WITH IT, MEASURED, NOT GUESSED. Over the whole capture library
(47 pcaps, five distinct responder nodes), of the 122 body offsets in
body[10:132] exactly ONE is nonzero in every real close: `body[24]`, 1308 of
1308, values {1,3,4,5}. Every other offset is zero somewhere -- a real close
with only ONE nonzero byte in that span exists, and that byte is this one.
OVMX sent 0. The predecessor userspace stack sent 3 or 4 there across ~14 runs
and never crashed a peer on a close.

ITS MEANING IS NOT GROUNDED, and this commit does not invent one (Rule 8). Four
derivations were tested and REFUTED, not assumed: it is not an echo (best
matching request offset agrees 358/1308 = 27%, no offset does better, so no
peer assigns it on the wire); it is not the op-0x01 PARAMS block sec 4(p) says
the close carries (every real PARAMS puts 0x0001 there while the same node's
closes put 3, 4 and 5); it is not per-node (every responder emits several
values); and the rest of the close body is demonstrably stale-buffer residue
(real closes carry OPCOM text and lock resource names there), which is what
first made "the payload is don't-care" look safe.

SO THE OMISSION MOVES UP A LEVEL. A fixed-width body cannot express "omitted":
for this field "omit" and "assert zero" are the same bytes, and zero is a value
no node has ever put on this wire. `vms_cm_close_build()` now takes the field
and REFUSES (VMS_CODEC_E_CLASS, writes nothing) when it is 0, so the codec is
structurally incapable of emitting the frame; `join_h_close()` withholds the
response, counts it (`closes_withheld`) and says so once on OPA0:. Not a join
failure -- nothing is broken, this node simply has no value for a mandatory
field and will not invent one (INV-6). `cnxman_join_cfg` carries the two cells
a future grounded source fills, and the host test proves BOTH branches, so
restoring the response is one glue assignment and no other change.

THE SECOND DEFECT, WHICH THE COORDINATOR NEVER LIVED LONG ENOUGH TO SHOW: every
one of OVMX's twelve barrier steps was going out with (txn, token) = (0, 0).
`cm_txn`/`cm_token` were read by the stamper and WRITTEN BY NOBODY in this
executive -- only by three test beds, which is exactly why the R1 suite was
green while the wire carried zeros. Measured per (category, opcode) over every
originated body in the corpus, the opcodes split cleanly in three and the split
is "does the sender expect an answer": always-nonzero (op 0x03 142/142, 0x05
282/282, 0x09 97/97, **0x0b 1035/1035**, cat-02 op 0x0d 21680/21680, cat-06
op 0x00 1361/1361) and always-zero (op 0x00, 0x02 95/95, 0x04, 0x06
12757/12757, 0x0a 125/125, 0x0c 1104/1104, 0x14 203/203, all of cat-04).
Twelve steps sharing one value are twelve requests a coordinator cannot match
to twelve releases -- a strictly worse version of the ordinal collision that
already stalled and then REGRESSED a real barrier.

FIX: `enum cnxman_envelope_kind` {RESPONSE, NOTIFY, REQUEST} replaces the
`is_response` int at `cnxman_envelope_originate()`. Only a REQUEST mints, from
the CSB's own monotonic counter (nonzero, wrap skips 0), which is this node's
own bookkeeping exactly like send-msg# -- not a reproduction of VMS's
derivation, which sec 4(j) records as UNKNOWN and Rule 8 forbids recomputing.
A new dialogue takes a new transaction id and restarts the token. Each of the
twelve call sites now states what its body is, and each is cited to its own
census population. `vms_cm_notification_zero_txn()` corrected to zero BOTH
cells: its note claimed the token was unconstrained; the wire says 125/125 and
1104/1104 carry zero.

GATE HOLE CLOSED. `cm_wire_safety_audit.py` reported 0 FATAL / 0 WARN on the
run that killed the coordinator. Two classes added -- S13-CLOSE-STATE-ZERO and
S14-REQUEST-PAIR-ZERO -- which flag frames 894 and 888 and NOTHING else on that
capture, with ZERO false positives across 101 reference-node sources in the
47-capture library. (E82's emit guard is untouched.)

TEETH, both proven by reverting each fix: without the mint, the new test
reports 12 zero pairs and 66 token collisions -- the live-wire condition
exactly; without the refusal, the close goes out and the mid-barrier
"emits NOTHING" assertion fails.

TESTS: cluster_host+cluster_sim 64/64, full ctest 240/240, unit 113/113,
wire_safety+emit_guard 2/2, includes-gate OK (75 files), wire-safety self-test
13/13 vectors with a clean clean-fixture, Linux vms.ko builds, NetBSD-VAX
`build-vms-module-vax.sh` ALL PROOFS PASSED (elf32-vax, ILP32-clean).
New: `test_e85_barrier_survives_to_member` drives the whole 12-step barrier
with the killing close arriving mid-barrier and asserts the barrier still
reaches op-0x0c #12 -> MEMBER, that no connect and no disconnect happens across
any of it, that MEMBER follows the real #12 and nothing synthetic, and that the
twelve steps carry twelve distinct nonzero pairs.
`test_correlation_pair_is_maintained` pins the REQUEST/RESPONSE/NOTIFY rule,
the per-dialogue restart and the nonzero wrap.

ESCALATION (Fable): body[24:26] of the cat-0x86 close is MANDATORY, its
meaning is unpublished, no peer assigns it on the wire, and this executive
holds nothing that derives it -- so OVMX now answers no close at all, while all
1308 real requests in the corpus are answered. Withholding is the safer of two
unprecedented behaviours (sec 4(p) gates the barrier on the cat-0x02 op-0x0d
rebuild records, not on this close), but it IS unprecedented and wants a
ruling: ground the field, or accept the silence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
baron-3dl added a commit that referenced this pull request Sep 10, 2026
… of the

same nodemap decode (ROUND-ROBIN CSV SLOT, settled)

Decode only; no code changed. Two captures re-decoded with a script that uses
no project code.

=== 1. The cn3 note's premise was scoped wrong, and the error mattered ===

cn3-achieved-20260905.md says "no cat 0x01 op=0x0c (barrier release) frame ever
appears". Whole-capture census: cn3 contains 01/0b x13 and **01/0c x12**. They
belong to a SECOND transition the note does not mention -- the epoch-6
class-0x03 REMOVE at t+1584.23s, VAX1 coordinating and VAX2 participating after
OVMXJ1 departed -- which walks the canonical barrier in full: 01/0b step N ->
81/0b ack -> 01/0c release N, N = 1..12, step index in body[16:20], no gaps.
op06-join shows the same 12-step walk for a class-0x02 ADD (frames 450-1107).
The SCOPED claim -- nothing toward OVMXJ1 -- stands, and is corrected in place.

=== 2. What actually commits the membership: PHASE 2 AT THE GO ===

The cn3 epoch-5 ADD really did stall its barrier: nodemap {1,2,3} -> M=3 -> 24
op-0b expected, exactly ONE observed (OVMXJ1's step 1, acked at frame 856, never
released). VAX1 sent no op-0b for epoch 5, and that is not a capture blind spot
-- the whole epoch-6 barrier between VAX1 and VAX2 is in the same file.

The membership committed anyway: VAX1 took the GO at frame 850 (t+18.9821s) and
counted CN_3 from then, sustained to t+600s, then ran a clean epoch-6 REMOVE 26
minutes later. That is p. 7-42 exactly -- Phase 2 (nodemap into the CSBs,
quorum, the count, the CLUSTER flag) runs at the GO; the 12-step barrier is the
lock-rebuild synchronisation that FOLLOWS the commit.

VERDICT: OVMX neither over- nor under-models this. barrier_h_go() already calls
barrier_commit_phase2() ("the count commits HERE ... p. 7-42") and
phase2_commit_local_membership() sets cl->state = MEMBER there. Only the join
FSM's OWN promotion waits for op-0c #12 (E79's deliberate choice) -- a narrower
thing than "am I a member", worth a decision but not a silent change.
NO CODE FIX WARRANTED from this decode.

E85's hypothesis that the barrier gates on cat-02 op-0d rebuild records is
REFUTED: cn3 has zero 02/0d frames in the entire capture, yet its epoch-6
barrier walked all 12 steps. Why VAX1 skipped the epoch-5 barrier is genuinely
AMBIGUOUS on the wire; the run that would settle it is a 3-VAX ADD with no OVMX
present (24 steps => OVMX implicated; early stop => M>=3 ADDs differ).

=== 3. vms-3a7c IS SETTLED -- by the same nodemap, by causality ===

The answer was never in the op-06 burst; it is in the transition-open NODEMAP,
whose bits ARE the CSV slots:

  epoch 5  frame   273  VAX2->VAX1    op-09 ADD     body[55]=0x0e = {1,2,3}
  epoch 5  frame   834  VAX2->OVMXJ1  op-09 ADD     body[55]=0x0e = {1,2,3}
  epoch 6  frame 11347  VAX1->VAX2    op-08 REMOVE  body[55]=0x06 = {1,2}

Three stations ever on that segment (1025, 1026, 1986). Bit 3 is present in
EXACTLY the transition that admits OVMXJ1 and absent in EXACTLY the one that
removes it; no other station enters or leaves. So slot 3 is OVMXJ1's -- and
OVMXJ1's SCSSYSTEMID is 1986, whose & 0x3ff is 962, a bit index the nodemap byte
cannot even express.

  => real VMS assigns the ROUND-ROBIN CSV SLOT, not SCSSYSTEMID & 0x3ff.

Corroborated independently in op06-join (different cluster incarnation, only
1025 and 1026 on the wire): its ADD open carries nodemap 0x0a = {1,3}, and both
bits must be real members (VAX1 coordinates, VAX2 walks the 12 steps), so one of
1025/1026 holds a slot neither 1025&0x3ff=1 nor 1026&0x3ff=2 can produce.

CONSEQUENCES:
 * OVMX's COORDINATOR (coord_next_slot round-robin) was already right -- no
   change, and the vms-3a7c ambiguity gate can eventually be retired rather
   than widened.
 * OVMX's JOINER self-derive is now PROVEN WRONG: for OVMXJ1 it computes
   0x000103C2 while the cluster assigned 0x00010003. With low word 962,
   phase2_csb_in_nodemap() finds the slot inexpressible, answers "unknown", and
   OVMX can never select itself into the map -- which is exactly why OVMX's own
   executive could not have read MEMBER in the CN=3 run even while VAX1's
   SHOW CLUSTER counted it. A real, grounded defect.
 * But the decode does NOT say HOW a joiner should learn its slot (the nodemap
   names slots, not systems). ESCALATED, not implemented.

=== 4. Two of my own earlier claims were WRONG; corrected in the note ===

 (a) "0x00010003 belongs to a system absent from the segment" -- FALSE; in
     op06-join slot 3 is held by one of the two VAXes that are present.
 (b) "A burst never carries its recipient's own CSID" -- FALSE, and it was the
     load-bearing step of the old sec 8 argument. op06-join's burst does carry
     it. cn3's burst lacks OVMXJ1's record for a TEMPORAL reason: frames
     245-833 precede the op-09 (834) that admitted it.

Host ctest still 247/247 (nothing built changed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKRFgtQkJPTJg7Gg5QyLcN
baron-3dl added a commit that referenced this pull request Sep 10, 2026
… assigned

CSID, and SYMMETRIC CN=2 lands

    RIG-A-FINAL role=founder member=1 state=MEMBER csid=0x00010001 cn=2 epoch=2 projections=agree
    RIG-B-FINAL role=joiner  member=1 state=MEMBER csid=0x00010002 cn=2 epoch=2 projections=agree
      GENESIS 2-NODE PROOF PASSED

Node B now COUNTS TWO. Its own CSB table names both systems (1026 ->
0x00010002 self, 1025 -> 0x00010001 peer, both MEMBER) and its $GETSYI reads
member=1 nodes=2 -- three projections that agree, all read back out of the
executive (INV-6).

GROUNDING FIRST. The claimed op-0x05 layout was verified against every real
op-0x05 frame in both repository captures BEFORE anything was built on it --
5 in cn3-achieved-20260905.pcap and 3 in op06-join-20260903.pcap, 8/8 agreeing:
body[16:20] the constant 0x00000220, body[20:24] SCSSYSTEMID, body[28:36] boot
time, body[36:40] assigned CSID, body[40:42] index == (CSID & 0xffff) - 1.
The pairings independently re-confirm the round-robin rule (cn3: 1025->slot 1,
1026->slot 2, 1986->slot 3 although 1986 & 0x3ff = 962; op06-join: 1026->slot 3
although 1026 & 0x3ff = 2), and the distribution is on the wire too: cn3 frames
230-233 carry the FULL set to the joiner, frame 235 carries only the DELTA to
the present member.

(a) COORDINATOR. vms_cm_membership_rec_build/_parse -- one shared form, one
    shared self-consistency predicate applied by builder and parser alike, so
    what OVMX emits is by construction what OVMX accepts. Only grounded fields;
    body[42:132] (uninterpreted stale buffer in the reference -- leftover
    strings and VAX kernel pointers) is emitted ZERO and COUNTED, never
    reproduced (Rule 8). coord_send_membership_set() distributes them the
    reference's way. EXECUTIVE-BACKED: every field is read off the CSB the
    record is about -- the SCSSYSTEMID the port learned, the CSID
    coord_assign_slot() actually stamped, that CSID's own slot, that member's
    real incarnation. A member this node holds no complete identity for gets NO
    record (counted); a record that does not hold together is refused by the
    codec and never becomes a frame.

(b) JOINER. join_adopt_membership_rec() adopts the CSID from the record naming
    its own SCSSYSTEMID -- re-adopting on every admission, caching nothing
    (p. 7-25: a rejoining system gets a NEW CSID; the oracle measured one
    SCSSYSTEMID taking slot 4 then slot 5). Records about OTHER members are
    filed on the block this CLUB already holds for that SCSSYSTEMID -- which is
    precisely what lets a joiner COUNT the cluster -- and a record about a
    system it holds no block for is counted and dropped, never invented.
    The generation << 16 | (SCSSYSTEMID & 0x3ff) SELF-DERIVE IS GONE: it gave
    OVMXJ1 low word 962 where the cluster had assigned slot 3, so
    phase2_csb_in_nodemap() could never match it and OVMX could never select
    itself into a cluster that had really admitted it.

(c) COMMIT AT THE GO (vms-9c99). The barrier gains phase2_commits, moved by
    barrier_commit_phase2(), and the join FSM promotes on that -- the same
    criterion membership and the coordinator already use. Grounded: cn3 shows a
    real VAX committing an ADD and counting its joiner from the GO for 600 s
    with no on-wire op-0x0c to that joiner at all, so the old op-0x0c #12
    trigger was an interop hang.

ALSO REMOVED: the interim vms-3a7c "ambiguity gate" I added earlier, which
admitted a system only when the round-robin slot and SCSSYSTEMID & 0x3ff
agreed. With the rule settled and the joiner adopting rather than deriving it
is unnecessary -- and it was actively WRONG: the oracle's own behaviour
(1986 -> slot 3) is exactly a case it would have refused. op-0x05 is renamed
from VMS_CM_OP_LOCKRB to VMS_CM_OP_MEMBREC so no reader can hold the old
"lock/resource rebuild" meaning.

(d) MIRRORS: none required -- no new TU, no new ioctl, nothing crossing the shr
    vector or vms_lock_nb.h. Verified.

CONTROLS, both run on the same image:
  noderive  node B's SCSSYSTEMID is 1030 (low ten bits = 6) and its executive
            reports CSID 0x00010002 -- CSV slot 2, the slot the coordinator
            ASSIGNED. A value it could not have computed, so the identity was
            demonstrably adopted off the wire. HELD.
  negctl    VOTES=0 on both: neither founds, neither reaches MEMBER, neither
            holds a CSID. HELD.

NOT CLAIMED, recorded rather than hidden: node B's quorum arithmetic is still
empty (quorum=0 cevotes=0) -- it counts both members but has not learned node
A's VOTES, which ride op-0x01 PARAMS, not the membership record. And node A
logs two "unroutable VMS$VAXcluster frame" lines: node B's 0x81/0x05 echoes,
which A's coordinator owns no edge for and says so rather than inventing one.

TESTS. Host ctest 250/250 (serial; opcom_record_body_gate times out only under
-j parallel load and passes standalone in 9.9 s -- pre-existing, unrelated).
New: test_membership_rec (builder/parser round-trip on the captures' own
vectors, the refusals, body[42:132] proven zero),
test_membership_records_are_projected_from_the_csbs (full-set/delta
distribution, and every emitted field compared against the CSB it is about),
test_slot_is_assigned_not_derivable, and four joiner cases -- adopt-when-named,
not-adopted-when-about-another (but filed on that peer), re-adopt-on-rejoin,
unusable-record-answered-not-adopted. The op-0x06 self-derive assertions were
re-pointed at the corrected behaviour, and the E79 promotion test now pins the
GO. cluster_core_includes_gate and cluster_wire_safety_gate pass; elf32-vax
cross-compiles + relocatable-links ILP32-clean; the Linux vms.ko is what the
rig boots.

Lab torn down (ovmx-genesis-rig + fetch pod deleted); vaxlab-* untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKRFgtQkJPTJg7Gg5QyLcN
baron-3dl added a commit that referenced this pull request Sep 10, 2026
…full membership (op-05 adopt, commit-at-GO) (#1119)

* vms-f6b: the hermetic 2-node cluster GENESIS rig -- and what it measured

THE RIG. Two minimal Linux guests, each loading the REAL executive (vms.ko)
and issuing STARTUP.EXE's own two cluster ioctls -- VMS_IOCTL_SYSGEN_LOAD then
VMS_IOCTL_CLUSTER_START -- on one L2 segment carried by a QEMU socket netdev
inside a single pod's netns (no bridge, no NET_ADMIN, no NET_RAW; only
/dev/kvm). There is NO userspace SCS daemon: CLUSTER_START opens the LAN port
INSIDE the executive and every 0x6007 frame on the segment is the executive's.
The guest-side `cluster_node` issues ioctls and READS STATE BACK; it never
touches the wire, never parses a frame and never computes a CSID, a member
count or a quorum. Every printed value is a field read from the executive one
line earlier through three independent projections (CLUSTER_DIAG_CSB's CLUB,
CLUSTER_GETSYI, CLUSTER_MEMBER_GET), and the verdict prints whether they AGREE
rather than picking one (INV-6). A value the executive has not learned prints
"-", never 0.

THE LIGHT PATH WORKS. No distro boot, no ODS-2 system disk, no STARTUP.EXE:
insmod + REGISTER + SYSGEN_LOAD + CLUSTER_START is enough to bring PEA0:, SCS
and the connection manager up on the guest's virtio NIC.

WHAT IT MEASURED -- GENESIS HOLDS AT RUNTIME, on a real executive:

  RIG-A-FINAL role=founder member=1 state=MEMBER csid=0x00010001 cn=1
              quorum=1 cevotes=1 epoch=1 projections=agree

Node A (VOTES=1, EXPECTED_VOTES=1, VAXCLUSTER=2) spent its whole RECNXINTERVAL
discovery window hearing nobody and formed generation 1 -- "%CNXMAN, this node
has quorum by its own votes: forming an OpenVMS Cluster" -> "this node is a
member of the cluster" -> "completed VAXcluster state transition" -- and its
executive reports MEMBER with the minted CSID and epoch 1.

NEGATIVE CONTROL (same image, same pod, VOTES=0 on BOTH nodes): neither node
founds, neither reaches MEMBER, neither holds a CSID. The control is stronger
than "CN != 2" deliberately, because genesis is what this rung actually
measures. Both transcripts are committed under tests/qemu/captures/.

WHAT IT DID NOT REACH, HONESTLY: CN=2. Node B stays JOINING with no CSID, so
the rig FAILS its own verdict and says so. The join now runs deep -- both
nodes open the VMS$VAXcluster VC and EMIT their cat-0x01 op-0x02 membership
request (RIG-*-JOINREC kind=2 cat=0x01 op=0x02) -- but neither node's
coordinator is ever driven by the peer's request, so admission times out on
both sides. Beyond that sits a second, structural wall: OVMX has no op-0x06
MEMBERSHIP builder (its payload map is not grounded -- Rule 8), and op-0x06 is
the ONLY path by which a joiner learns the generation it needs for its own
CSID, which phase 2 needs to set the local CSB's MEMBER flag. Both are named
in the report, not worked around here.

TWO REAL DEFECTS THE RIG FOUND, AND FIXED:

 1. vms_lan_rx_thunk() linearized a SHARED skb -- src/kernel/
    exec_kbackend_linux.h. A packet_type handler is not the exclusive owner of
    its skb: with anything else listening on the same NIC it arrives via
    deliver_skb() with an extra reference, and pskb_expand_head() asserts
    BUG_ON(skb_shared(skb)). MEASURED: with a passive AF_PACKET capture bound
    to the same interface BOTH guests took a kernel panic ("kernel BUG at
    net/core/skbuff.c:2138 ... vms_lan_rx_thunk [vms]") on the FIRST 0x6007
    frame -- i.e. tcpdump on a clustered OVMX node crashed that node. Fixed
    with skb_share_check(), the contract every in-tree ptype handler observes.
    Proven by the same rig: 150 s clean with the capture bound, and an 81 KB
    pcap per node where there used to be a panic.

 2. cnxman_join_drive() downgraded a MEMBER to JOINING -- src/kernel-core/
    vms_cnxman.c. The beat drives a join whenever a system is present and the
    join FSM is idle, INCLUDING on a node that is already a member (either
    side may open the VMS$VAXcluster connection, E67). The unconditional
    `cl->state = VMS_CLUSTER_JOINING` therefore un-asserted a membership the
    executive genuinely held: the local CSB still carried MEMBER and the CLUB
    still carried the CSID, while the one cell SYI$_CLUSTER_MEMBER and SHOW
    CLUSTER read said JOINING. MEASURED: node A founded the cluster, was
    committed MEMBER by phase 2, and reported member=0 the instant node B was
    powered on -- a cluster's founder and coordinator describing itself as not
    a member of it. Only phase 2 may write MEMBER and only a real transition
    may take it away; a join ATTEMPT is neither.

TWO RIG-LEVEL FINDINGS RECORDED IN THE CODE:

 - CLUSTER_CREDITS is load-bearing. With it unset the port advertises a grant
   of 0 at abs 95, the peer honours "no credit, no message" (p. 2-43) and the
   circuit opens with NOTHING able to travel on it: measured as both VCs OPEN,
   credits_send=0, zero sequenced frames either way. The executive's behaviour
   was exactly right (it counts vc_credits_absent and refuses); the rig was
   misconfigured. It now loads 32, VMS's own default.
 - QEMU's socket,mcast= netdev loops a guest's own group multicast back to it,
   and a node booted ALONE formed a channel with ITSELF off its own HELLO
   ("%PEA0, channel verified" with no peer on the segment). The rig uses a
   point-to-point socket instead. That the port accepts a HELLO bearing its
   own SCSSYSTEMID is a separate robustness observation, recorded not patched.

TESTS RUN. Full host ctest 247/247 pass, including a new source scan in
test_cnxman_glue.c pinning fix 2 (the guarded write present, the unguarded one
gone). cluster_core_includes_gate and cluster_wire_safety_gate pass. The Linux
vms.ko builds clean (it is what the rig boots) and the whole executive module
cross-compiles + relocatable-links for elf32-vax against real NetBSD/vax
kernel headers, ILP32 width-clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKRFgtQkJPTJg7Gg5QyLcN

* vms-f6b/vms-1ee: the op-0x06 MEMBERSHIP builder -- an OVMX coordinator can
admit a joiner, and the joiner reaches MEMBER on a wire-learned CSID

DELIVERABLE 1 -- docs/design-op06-membership-builder.md. Every field of the
cat-0x01 op-0x06 record, grounded or honestly omitted, re-decoded from the
captures by a script that uses NO project code: primary
tests/lab/captures/op06-join-20260903.pcap (255 real op-0x06 frames, VAX1
coordinator) cross-referenced against cn3-achieved-20260905.pcap (254 frames,
VAX2 coordinator, a DIFFERENT cluster and epoch -- and the burst OVMX itself
consumed to reach CN=3), with scs-tier0 grounding SCSSYSTEMID inside the
cluster-LOGICAL LAN address (aa:00:04:00:01:04 -> 0x0401 = 1025).

The whole 24-byte CM header is byte-identical across all 509 frames: category
0x01, opcode 0x06, role 0x20 (COMMIT), class 0x02 (ADD), body[18:20] zero --
and body[12:16] the sender's real transition epoch (6 in one capture, 5 in the
other). Form A's CSID at body[24:28] carries ONLY genuine CSIDs, 24/24 in each
capture, zero false positives.

THE :715 QUESTION, ANSWERED BY MEASUREMENT. That note forbids a zero-filled
op-0x06 on the premise that the burst carries a membership LIST, so zeros would
assert an empty cluster. E30 falsified that premise and the captures refute it:
23 of 255 real op-0x06 frames -- and 23 of 254 in the second capture -- carry an
ENTIRELY ZERO body[24:132], interleaved through the burst (first at position 5
of 255, then every ~11 frames), in joins that SUCCEEDED. A zero payload is a
shape the reference emits as a matter of course. The prohibition still stands
for the two opcodes that note also names, and neither gains a builder here.

DELIVERABLE 2 -- vms_cm_membership_build(). ONE frame per admission (the
reference's 254-frame burst is E78's crash vector), carrying the coordinator's
OWN real CSID read from club->local_csid at the form-A offset -- the offset the
receive path tries FIRST, so builder and reader share one form, enforced by a
round-trip test. A value failing the SAME shape test the reader applies is
VMS_CODEC_E_RANGE and no frame is built. Seven nonzero bytes in the whole 132,
asserted as such; the countdown, the incarnation and the sub-record body are
left zero and COUNTED (membership_fields_omitted).

MEASURED ON TWO REAL EXECUTIVES (rig on the k3s worker, KVM; every value read
back through CLUSTER_DIAG_CSB / _GETSYI / _MEMBER_GET):

  RIG-A-FINAL role=founder               member=1 MEMBER csid=0x00010001 cn=2 epoch=2
  RIG-B-FINAL role=member-no-coordinator member=1 MEMBER csid=0x00010002 cn=1 epoch=2

  node A: %CNXMAN, proposing addition of a system to the cluster
          %CNXMAN, system 0000000000000402 was added to the cluster
  node B: %CNXMAN, this node is a member of the cluster

Node B did not receive that CSID -- it read a GENERATION out of node A's
op-0x06 and computed 1<<16 | (1026 & 0x3ff) from its own SYSGEN state, and
phase 2 committed its membership off the nodemap bit node A really asserted.

TWO EXECUTIVE DEFECTS THE RIG FOUND ON THE WAY, both blocking any admission:

 1. THE JOINER'S ADVERT HANDLER SWALLOWED THE op-0x02 MEMBERSHIP REQUEST.
    join_h_peer_advert() consumed cat-0x01 op-0x02 as a "peer advert", and the
    router offers a body to the join FSM FIRST -- so the coordinator's ONE
    selection edge ([IDLE][RX_TR_REQUEST]; being asked is what MAKES a node the
    coordinator, book pp. 7-37/7-38) was never reached. Measured: both nodes put
    their op-0x02 on the wire for a whole run and neither ever proposed
    anything. It now returns NOT_MINE for that one opcode -- op-0x01 is handled
    there and op-0x14 has no other owner, so neither becomes an unroutable
    frame.
 2. Two silent refusal paths in the coordinator now say what they did once.
    A transition that stalls in COMMIT used to leave no trace at all: the
    counters are projected through no ioctl and the executive has no console
    log of its own.

CSID ASSIGNMENT IS PROVISIONAL PENDING ORACLE (vms-3a7c), and is GATED rather
than kill-switched. coord_csid_unambiguous(): admit a subject only when the
round-robin CSV slot and the joiner's own SCSSYSTEMID & 0x3ff -- the two
candidate rules -- name it IDENTICALLY. Under that gate the CSID OVMX asserts is
correct whichever way the oracle settles, which is the only "safe toward a real
VAX" available before the answer exists. A disagreement is a named refusal with
nothing stamped and nothing emitted. The note lays out both candidate rules,
what the captures ground (every real CSID is consistent with BOTH -- the lab's
SCSSYSTEMIDs are consecutive from 1025, so no capture can separate them) and the
single lab run that would.

THREE RIG MODES, all three RUN:
  proof  A founds, admits B; both MEMBER; A cn=2, B cn=1 (see below) -- the
         rig FAILS its own strict verdict and says precisely what held.
  ambig  node B's SCSSYSTEMID 1030 makes the two rules disagree: node A refuses
         ("cannot be assigned unambiguously"), sends NO op-0x06, and node B
         stays role=none member=0 csid=- JOINING. HELD. This is the INV-6
         control on the joiner -- same code, same wire, one SYSGEN digit apart.
  negctl VOTES=0 on both: nobody founds, nobody reaches MEMBER, nobody holds a
         CSID. HELD.

THE REMAINING GAP, STATED NOT PAPERED OVER (note sec 8). Node B counts 1 member,
not 2: phase 2 can only match a CSB to a nodemap bit if that CSB's CSID is
known, and no grounded wire field associates a PEER's SCSSYSTEMID with its CSID.
B's own executive says so -- "%CNXMAN, committed member count differs from the
transition nodemap". B cannot take the CSID off the op-0x06 it just read,
because E30 and the captures are explicit that a burst carries the sender's own
CSID OR another member's: in cn3 the sender is VAX2 (1026) and its form-A record
carries VAX1's 0x00010001. Attributing it to the sender is a fabrication the
capture refutes, so it is NOT done. Two candidate closures that would work
inside an OVMX-coordinated cluster but assert the unresolved vms-3a7c rule about
a system this node was never told about are written down and ESCALATED, not
implemented; the clean answer is one lab capture, which would settle vms-3a7c in
the same run.

TESTS RUN. Host ctest 247/247. New: test_membership_build (builder bytes,
refusal, round-trip through this codec's own reader, every-nonzero-byte count),
test_membership_record_is_built_from_real_state (the emitted frame's grounded
bytes and its zeros, against real CLUB state) and
test_ambiguous_csid_assignment_refuses. test_cnxman_coord's bed now carries REAL
SCSSYSTEMIDs consistent with its own CSID constants (1025/1026/1027/1028) rather
than LAN-address-shaped 48-bit values for which slot != sysid & 0x3ff -- a
fixture made faithful, with every assertion unchanged. cluster_core_includes_gate
and cluster_wire_safety_gate pass. Linux vms.ko builds (it is what the rig
boots) and the whole executive module cross-compiles + relocatable-links for
elf32-vax against real NetBSD/vax headers, ILP32 width-clean.

NO REAL-VAX EMISSION. The rig is all-OVMX and emits toward no VAX. Nothing here
is claimed interop-verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKRFgtQkJPTJg7Gg5QyLcN

* vms-3a7c: cn3 does NOT settle the CSID-assignment rule -- decoded, and why

The hypothesis was attractive: cn3-achieved-20260905.pcap admits OVMXJ1, whose
SCSSYSTEMID 1986 is non-consecutive (1986 & 0x3ff = 962 = 0x3C2 vs next free CSV
slot 3), so if the coordinator's assignment for it were visible the two rival
rules would predict 0x00010003 vs 0x000103C2 and vms-3a7c would be over from
existing data. Decoded. It is not visible, for three independent reasons, each
measured:

 1. NO PRE-JOIN BASELINE. cn3 contains ZERO cat-0x01 op-0x06 frames before
    OVMXJ1 first speaks (t+15.247s). All 254 are VAX2 -> OVMXJ1 and begin at
    t+18.933s BECAUSE of the join. All three CSIDs (0x00010001/2/3) first appear
    within ONE MILLISECOND of each other in the burst's opening frames, so
    "a third CSID appears when the new member arrives" is unobservable -- every
    CSID appears then. The temporal discriminator has nothing to discriminate
    against.

 2. 0x00010003 IS NOT OVMXJ1's, AND THE SIBLING CAPTURE PROVES IT.
    op06-join-20260903.pcap has only TWO stations on the wire -- 1025 and 1026,
    no third VAX at all -- and its burst nonetheless asserts 0x00010003 x46 at
    the grounded form-B offset, the IDENTICAL multiplicity cn3 shows. That
    record therefore belongs to a member of the coordinator's own table that is
    ABSENT from the segment (p. 7-25's retained CSB), and cn3 re-asserts the
    same foreign record. The elimination argument ("three stations, three
    CSIDs, so the third is the joiner's") fails on this.

 3. A BURST NEVER CARRIES ITS RECIPIENT'S OWN CSID -- E30 from a second angle:
      VAX1 -> VAX2    : 0x00010001 x47, 0x00010003 x46   (0x00010002 ABSENT)
      VAX2 -> OVMXJ1  : 0x00010001 x47, 0x00010002 x23, 0x00010003 x46
                                                        (OVMXJ1's ABSENT)
    So OVMXJ1's assigned CSID is not in cn3 at all, under either rule.

The negative check is inconclusive, not confirmatory: 0x000103C2 occurs 0 times
in all 11,478 SCA frames of cn3 -- but since the burst structurally omits the
recipient's own CSID, that absence is exactly what BOTH rules predict.
(A bare value-scan for 0x00010003 across all frames finds 302 hits, but 242 sit
at abs 82 = body[10], the uninitialised-residue span, and the first is 6.8s
BEFORE OVMXJ1 exists -- which is its own warning against attributing by value.)

WHAT THIS BUYS ANYWAY: the required lab capture is now sharply specified rather
than merely named. It must (a) capture the coordinator's burst toward an
ALREADY-PRESENT member, never toward the joiner -- point 3 is why -- and (b)
include a PRE-ADMISSION baseline, which cn3 lacks entirely. With the new node's
SCSSYSTEMID & 0x3ff chosen NOT to equal the next free CSV slot, that one run
settles vms-3a7c AND grounds the {SCSSYSTEMID -> CSID} association sec 8 needs
for symmetric CN=2.

Note-only; no code changed. The provisional round-robin rule and the sec 5
ambiguity gate stand exactly as they were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKRFgtQkJPTJg7Gg5QyLcN

* vms-fc7: ground the transition-commit model -- and vms-3a7c falls out of the
same nodemap decode (ROUND-ROBIN CSV SLOT, settled)

Decode only; no code changed. Two captures re-decoded with a script that uses
no project code.

=== 1. The cn3 note's premise was scoped wrong, and the error mattered ===

cn3-achieved-20260905.md says "no cat 0x01 op=0x0c (barrier release) frame ever
appears". Whole-capture census: cn3 contains 01/0b x13 and **01/0c x12**. They
belong to a SECOND transition the note does not mention -- the epoch-6
class-0x03 REMOVE at t+1584.23s, VAX1 coordinating and VAX2 participating after
OVMXJ1 departed -- which walks the canonical barrier in full: 01/0b step N ->
81/0b ack -> 01/0c release N, N = 1..12, step index in body[16:20], no gaps.
op06-join shows the same 12-step walk for a class-0x02 ADD (frames 450-1107).
The SCOPED claim -- nothing toward OVMXJ1 -- stands, and is corrected in place.

=== 2. What actually commits the membership: PHASE 2 AT THE GO ===

The cn3 epoch-5 ADD really did stall its barrier: nodemap {1,2,3} -> M=3 -> 24
op-0b expected, exactly ONE observed (OVMXJ1's step 1, acked at frame 856, never
released). VAX1 sent no op-0b for epoch 5, and that is not a capture blind spot
-- the whole epoch-6 barrier between VAX1 and VAX2 is in the same file.

The membership committed anyway: VAX1 took the GO at frame 850 (t+18.9821s) and
counted CN_3 from then, sustained to t+600s, then ran a clean epoch-6 REMOVE 26
minutes later. That is p. 7-42 exactly -- Phase 2 (nodemap into the CSBs,
quorum, the count, the CLUSTER flag) runs at the GO; the 12-step barrier is the
lock-rebuild synchronisation that FOLLOWS the commit.

VERDICT: OVMX neither over- nor under-models this. barrier_h_go() already calls
barrier_commit_phase2() ("the count commits HERE ... p. 7-42") and
phase2_commit_local_membership() sets cl->state = MEMBER there. Only the join
FSM's OWN promotion waits for op-0c #12 (E79's deliberate choice) -- a narrower
thing than "am I a member", worth a decision but not a silent change.
NO CODE FIX WARRANTED from this decode.

E85's hypothesis that the barrier gates on cat-02 op-0d rebuild records is
REFUTED: cn3 has zero 02/0d frames in the entire capture, yet its epoch-6
barrier walked all 12 steps. Why VAX1 skipped the epoch-5 barrier is genuinely
AMBIGUOUS on the wire; the run that would settle it is a 3-VAX ADD with no OVMX
present (24 steps => OVMX implicated; early stop => M>=3 ADDs differ).

=== 3. vms-3a7c IS SETTLED -- by the same nodemap, by causality ===

The answer was never in the op-06 burst; it is in the transition-open NODEMAP,
whose bits ARE the CSV slots:

  epoch 5  frame   273  VAX2->VAX1    op-09 ADD     body[55]=0x0e = {1,2,3}
  epoch 5  frame   834  VAX2->OVMXJ1  op-09 ADD     body[55]=0x0e = {1,2,3}
  epoch 6  frame 11347  VAX1->VAX2    op-08 REMOVE  body[55]=0x06 = {1,2}

Three stations ever on that segment (1025, 1026, 1986). Bit 3 is present in
EXACTLY the transition that admits OVMXJ1 and absent in EXACTLY the one that
removes it; no other station enters or leaves. So slot 3 is OVMXJ1's -- and
OVMXJ1's SCSSYSTEMID is 1986, whose & 0x3ff is 962, a bit index the nodemap byte
cannot even express.

  => real VMS assigns the ROUND-ROBIN CSV SLOT, not SCSSYSTEMID & 0x3ff.

Corroborated independently in op06-join (different cluster incarnation, only
1025 and 1026 on the wire): its ADD open carries nodemap 0x0a = {1,3}, and both
bits must be real members (VAX1 coordinates, VAX2 walks the 12 steps), so one of
1025/1026 holds a slot neither 1025&0x3ff=1 nor 1026&0x3ff=2 can produce.

CONSEQUENCES:
 * OVMX's COORDINATOR (coord_next_slot round-robin) was already right -- no
   change, and the vms-3a7c ambiguity gate can eventually be retired rather
   than widened.
 * OVMX's JOINER self-derive is now PROVEN WRONG: for OVMXJ1 it computes
   0x000103C2 while the cluster assigned 0x00010003. With low word 962,
   phase2_csb_in_nodemap() finds the slot inexpressible, answers "unknown", and
   OVMX can never select itself into the map -- which is exactly why OVMX's own
   executive could not have read MEMBER in the CN=3 run even while VAX1's
   SHOW CLUSTER counted it. A real, grounded defect.
 * But the decode does NOT say HOW a joiner should learn its slot (the nodemap
   names slots, not systems). ESCALATED, not implemented.

=== 4. Two of my own earlier claims were WRONG; corrected in the note ===

 (a) "0x00010003 belongs to a system absent from the segment" -- FALSE; in
     op06-join slot 3 is held by one of the two VAXes that are present.
 (b) "A burst never carries its recipient's own CSID" -- FALSE, and it was the
     load-bearing step of the old sec 8 argument. op06-join's burst does carry
     it. cn3's burst lacks OVMXJ1's record for a TEMPORAL reason: frames
     245-833 precede the op-09 (834) that admitted it.

Host ctest still 247/247 (nothing built changed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKRFgtQkJPTJg7Gg5QyLcN

* vms-fc7/vms-9c99: op-0x05 membership records -- the joiner ADOPTS its assigned
CSID, and SYMMETRIC CN=2 lands

    RIG-A-FINAL role=founder member=1 state=MEMBER csid=0x00010001 cn=2 epoch=2 projections=agree
    RIG-B-FINAL role=joiner  member=1 state=MEMBER csid=0x00010002 cn=2 epoch=2 projections=agree
      GENESIS 2-NODE PROOF PASSED

Node B now COUNTS TWO. Its own CSB table names both systems (1026 ->
0x00010002 self, 1025 -> 0x00010001 peer, both MEMBER) and its $GETSYI reads
member=1 nodes=2 -- three projections that agree, all read back out of the
executive (INV-6).

GROUNDING FIRST. The claimed op-0x05 layout was verified against every real
op-0x05 frame in both repository captures BEFORE anything was built on it --
5 in cn3-achieved-20260905.pcap and 3 in op06-join-20260903.pcap, 8/8 agreeing:
body[16:20] the constant 0x00000220, body[20:24] SCSSYSTEMID, body[28:36] boot
time, body[36:40] assigned CSID, body[40:42] index == (CSID & 0xffff) - 1.
The pairings independently re-confirm the round-robin rule (cn3: 1025->slot 1,
1026->slot 2, 1986->slot 3 although 1986 & 0x3ff = 962; op06-join: 1026->slot 3
although 1026 & 0x3ff = 2), and the distribution is on the wire too: cn3 frames
230-233 carry the FULL set to the joiner, frame 235 carries only the DELTA to
the present member.

(a) COORDINATOR. vms_cm_membership_rec_build/_parse -- one shared form, one
    shared self-consistency predicate applied by builder and parser alike, so
    what OVMX emits is by construction what OVMX accepts. Only grounded fields;
    body[42:132] (uninterpreted stale buffer in the reference -- leftover
    strings and VAX kernel pointers) is emitted ZERO and COUNTED, never
    reproduced (Rule 8). coord_send_membership_set() distributes them the
    reference's way. EXECUTIVE-BACKED: every field is read off the CSB the
    record is about -- the SCSSYSTEMID the port learned, the CSID
    coord_assign_slot() actually stamped, that CSID's own slot, that member's
    real incarnation. A member this node holds no complete identity for gets NO
    record (counted); a record that does not hold together is refused by the
    codec and never becomes a frame.

(b) JOINER. join_adopt_membership_rec() adopts the CSID from the record naming
    its own SCSSYSTEMID -- re-adopting on every admission, caching nothing
    (p. 7-25: a rejoining system gets a NEW CSID; the oracle measured one
    SCSSYSTEMID taking slot 4 then slot 5). Records about OTHER members are
    filed on the block this CLUB already holds for that SCSSYSTEMID -- which is
    precisely what lets a joiner COUNT the cluster -- and a record about a
    system it holds no block for is counted and dropped, never invented.
    The generation << 16 | (SCSSYSTEMID & 0x3ff) SELF-DERIVE IS GONE: it gave
    OVMXJ1 low word 962 where the cluster had assigned slot 3, so
    phase2_csb_in_nodemap() could never match it and OVMX could never select
    itself into a cluster that had really admitted it.

(c) COMMIT AT THE GO (vms-9c99). The barrier gains phase2_commits, moved by
    barrier_commit_phase2(), and the join FSM promotes on that -- the same
    criterion membership and the coordinator already use. Grounded: cn3 shows a
    real VAX committing an ADD and counting its joiner from the GO for 600 s
    with no on-wire op-0x0c to that joiner at all, so the old op-0x0c #12
    trigger was an interop hang.

ALSO REMOVED: the interim vms-3a7c "ambiguity gate" I added earlier, which
admitted a system only when the round-robin slot and SCSSYSTEMID & 0x3ff
agreed. With the rule settled and the joiner adopting rather than deriving it
is unnecessary -- and it was actively WRONG: the oracle's own behaviour
(1986 -> slot 3) is exactly a case it would have refused. op-0x05 is renamed
from VMS_CM_OP_LOCKRB to VMS_CM_OP_MEMBREC so no reader can hold the old
"lock/resource rebuild" meaning.

(d) MIRRORS: none required -- no new TU, no new ioctl, nothing crossing the shr
    vector or vms_lock_nb.h. Verified.

CONTROLS, both run on the same image:
  noderive  node B's SCSSYSTEMID is 1030 (low ten bits = 6) and its executive
            reports CSID 0x00010002 -- CSV slot 2, the slot the coordinator
            ASSIGNED. A value it could not have computed, so the identity was
            demonstrably adopted off the wire. HELD.
  negctl    VOTES=0 on both: neither founds, neither reaches MEMBER, neither
            holds a CSID. HELD.

NOT CLAIMED, recorded rather than hidden: node B's quorum arithmetic is still
empty (quorum=0 cevotes=0) -- it counts both members but has not learned node
A's VOTES, which ride op-0x01 PARAMS, not the membership record. And node A
logs two "unroutable VMS$VAXcluster frame" lines: node B's 0x81/0x05 echoes,
which A's coordinator owns no edge for and says so rather than inventing one.

TESTS. Host ctest 250/250 (serial; opcom_record_body_gate times out only under
-j parallel load and passes standalone in 9.9 s -- pre-existing, unrelated).
New: test_membership_rec (builder/parser round-trip on the captures' own
vectors, the refusals, body[42:132] proven zero),
test_membership_records_are_projected_from_the_csbs (full-set/delta
distribution, and every emitted field compared against the CSB it is about),
test_slot_is_assigned_not_derivable, and four joiner cases -- adopt-when-named,
not-adopted-when-about-another (but filed on that peer), re-adopt-on-rejoin,
unusable-record-answered-not-adopted. The op-0x06 self-derive assertions were
re-pointed at the corrected behaviour, and the E79 promotion test now pins the
GO. cluster_core_includes_gate and cluster_wire_safety_gate pass; elf32-vax
cross-compiles + relocatable-links ILP32-clean; the Linux vms.ko is what the
rig boots.

Lab torn down (ovmx-genesis-rig + fetch pod deleted); vaxlab-* untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKRFgtQkJPTJg7Gg5QyLcN

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant