diff --git a/.golangci.yml b/.golangci.yml index 3b58b7e98..b38645ded 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -117,6 +117,7 @@ linters: # length fields) and io_uring batch indices; all are bounds-checked. - G115 # G204: cmd/ns and cmd/nsTest spawn `ip netns exec ...`; required by design. + # cmd/xtcp2's soft restart execs os.Executable(), which no constant names. - G204 # G304: register_schema reads .proto files whose path comes from CLI flags. - G304 diff --git a/cmd/ipfeed-collector/main_test.go b/cmd/ipfeed-collector/main_test.go index 8f9fe1952..30ab4e407 100644 --- a/cmd/ipfeed-collector/main_test.go +++ b/cmd/ipfeed-collector/main_test.go @@ -13,22 +13,16 @@ import ( "time" ) -// setOrUnset sets key to val when set is true, otherwise ensures it is unset, -// registering cleanup so the surrounding environment is restored. +// setOrUnset sets key to val when set is true, otherwise ensures it is unset. +// t.Setenv records the surrounding value, set or not, and restores it at +// cleanup. func setOrUnset(t *testing.T, key, val string, set bool) { t.Helper() - prev, had := os.LookupEnv(key) - t.Cleanup(func() { - if had { - os.Setenv(key, prev) //nolint:errcheck,gosec // test env restore; failure is not actionable - } else { - os.Unsetenv(key) //nolint:errcheck,gosec // test env restore; failure is not actionable + t.Setenv(key, val) + if !set { + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) } - }) - if set { - os.Setenv(key, val) //nolint:errcheck,gosec // test env setup; failure is not actionable - } else { - os.Unsetenv(key) //nolint:errcheck,gosec // test env setup; failure is not actionable } } diff --git a/cmd/xtcp2/reexec_handoff.go b/cmd/xtcp2/reexec_handoff.go new file mode 100644 index 000000000..5427a75e9 --- /dev/null +++ b/cmd/xtcp2/reexec_handoff.go @@ -0,0 +1,169 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "strconv" + + "github.com/randomizedcoder/xtcp2/gen/go/xtcp_config" + "github.com/randomizedcoder/xtcp2/pkg/xtcp" + "golang.org/x/sys/unix" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +// The soft restart's handoff to the image it execs into. +// +// - The config, its secrets blanked, goes in reconfigureEnvKey. The +// environment stays the readable record of what the daemon runs, so +// /proc//environ shows the config but not the secrets, which the +// operator may have kept out of it with the *_FILE variables. +// - The secrets go in a sealed memfd, inherited across the exec, whose +// descriptor number is in reconfigureMemfdFDKey. It is anonymous +// memory: nothing on disk, and nothing in the environment but a number. +// - argv is rebuilt from the flags the command line set, not copied from +// os.Args, so it carries only flags this binary defines. + +const ( + // reconfigureMemfdFDKey names the descriptor that carries the + // config's secrets across the exec. + reconfigureMemfdFDKey = "XTCP_CONFIG_SECRETS_FD" + // reconfigureMemfdName is the memfd's name, as /proc//fd shows it. + reconfigureMemfdName = "xtcp2-reconfigure-secrets" + // reconfigureSecretsMaxBytes bounds the read: six strings, the listener + // ones validated at 4 KiB each. + reconfigureSecretsMaxBytes = 1 << 20 + // reconfigureSecretSeals are the seals the memfd carries: once written, + // its contents can't change and no seal can be removed. + reconfigureSecretSeals = unix.F_SEAL_SEAL | unix.F_SEAL_SHRINK | unix.F_SEAL_GROW | unix.F_SEAL_WRITE +) + +var errReconfigureSecrets = errors.New("reconfigure secrets") + +// splitSecrets returns cfg with its secrets blanked by ConfigService's +// xtcp.RedactSecrets, and a config holding only them. mergeSecrets is its +// inverse. TestSplitSecretsMatchesRedactSecrets fails if the fields carried +// here and the fields RedactSecrets blanks drift apart. +func splitSecrets(cfg *xtcp_config.XtcpConfig) (redacted, secrets *xtcp_config.XtcpConfig) { + redacted, ok := proto.Clone(cfg).(*xtcp_config.XtcpConfig) + if !ok { + redacted = &xtcp_config.XtcpConfig{} + } + xtcp.RedactSecrets(redacted) + secrets = &xtcp_config.XtcpConfig{ + S3SecretKey: cfg.GetS3SecretKey(), + S3AccessKey: cfg.GetS3AccessKey(), + ListenerAuth: authSecrets(cfg.GetListenerAuth()), + AdminListenerAuth: authSecrets(cfg.GetAdminListenerAuth()), + } + return redacted, secrets +} + +// authSecrets is a listener auth block's secrets alone; nil when it has none. +func authSecrets(a *xtcp_config.ListenerAuth) *xtcp_config.ListenerAuth { + if a.GetRawToken() == "" && a.GetHmacSharedKey() == "" { + return nil + } + return &xtcp_config.ListenerAuth{RawToken: a.GetRawToken(), HmacSharedKey: a.GetHmacSharedKey()} +} + +// mergeSecrets puts splitSecrets' secrets back into its redacted config. +func mergeSecrets(cfg, secrets *xtcp_config.XtcpConfig) { + cfg.S3SecretKey = secrets.GetS3SecretKey() + cfg.S3AccessKey = secrets.GetS3AccessKey() + cfg.ListenerAuth = mergeAuthSecrets(cfg.ListenerAuth, secrets.GetListenerAuth()) + cfg.AdminListenerAuth = mergeAuthSecrets(cfg.AdminListenerAuth, secrets.GetAdminListenerAuth()) +} + +func mergeAuthSecrets(a, s *xtcp_config.ListenerAuth) *xtcp_config.ListenerAuth { + if s == nil { + return a + } + if a == nil { + a = &xtcp_config.ListenerAuth{} + } + a.RawToken, a.HmacSharedKey = s.GetRawToken(), s.GetHmacSharedKey() + return a +} + +// secretsMemfd writes b to a sealed memfd the exec'd image inherits: it is +// made without MFD_CLOEXEC, and syscall.Exec closes only close-on-exec +// descriptors. b past reconfigureSecretsMaxBytes is refused here, since +// the exec'd image would refuse it after this one is gone. +func secretsMemfd(b []byte) (f *os.File, fd int, err error) { + if len(b) > reconfigureSecretsMaxBytes { + return nil, -1, fmt.Errorf("%w: %d bytes, past %d", errReconfigureSecrets, len(b), reconfigureSecretsMaxBytes) + } + fd, err = unix.MemfdCreate(reconfigureMemfdName, unix.MFD_ALLOW_SEALING) + if err != nil { + return nil, -1, fmt.Errorf("%w: memfd_create: %w", errReconfigureSecrets, err) + } + f = os.NewFile(uintptr(fd), reconfigureMemfdName) + if _, err = f.Write(b); err == nil { + _, err = unix.FcntlInt(f.Fd(), unix.F_ADD_SEALS, reconfigureSecretSeals) + } + if err != nil { + return nil, -1, errors.Join(fmt.Errorf("%w: %w", errReconfigureSecrets, err), f.Close()) + } + return f, fd, nil +} + +// loadReconfigureSecrets reads the secrets a soft restart carried in a +// memfd into cfg, and closes it. It does nothing when the restart carried +// none. The descriptor must be a memfd with every seal secretsMemfd adds, +// so an inherited stdin or a stray file is refused rather than read; a +// memfd that is refused is closed too. +func loadReconfigureSecrets(cfg *xtcp_config.XtcpConfig) (err error) { + v, present := os.LookupEnv(reconfigureMemfdFDKey) + if !present || v == "" { + return nil + } + fd, err := strconv.ParseUint(v, 10, 31) + if err != nil || fd < 3 { + return fmt.Errorf("%w: %s=%q is not a descriptor", errReconfigureSecrets, reconfigureMemfdFDKey, v) + } + seals, err := unix.FcntlInt(uintptr(fd), unix.F_GET_SEALS, 0) + if err != nil { + return fmt.Errorf("%w: descriptor %d is not a memfd: %w", errReconfigureSecrets, fd, err) + } + // A memfd named by the key is the carry's, so it is closed from here + // on, read or refused. + f := os.NewFile(uintptr(fd), reconfigureMemfdName) + defer func() { + err = errors.Join(err, f.Close()) + }() + if seals&reconfigureSecretSeals != reconfigureSecretSeals { + return fmt.Errorf("%w: descriptor %d is not sealed (seals %#x)", errReconfigureSecrets, fd, seals) + } + st, err := f.Stat() + if err != nil { + return fmt.Errorf("%w: %w", errReconfigureSecrets, err) + } + if st.Size() > reconfigureSecretsMaxBytes { + return fmt.Errorf("%w: %d bytes, past %d", errReconfigureSecrets, st.Size(), reconfigureSecretsMaxBytes) + } + b, err := io.ReadAll(io.NewSectionReader(f, 0, st.Size())) + if err != nil { + return fmt.Errorf("%w: %w", errReconfigureSecrets, err) + } + secrets := &xtcp_config.XtcpConfig{} + if err := protojson.Unmarshal(b, secrets); err != nil { + return fmt.Errorf("%w: %w", errReconfigureSecrets, err) + } + mergeSecrets(cfg, secrets) + return os.Unsetenv(reconfigureMemfdFDKey) +} + +// reexecArgv is the soft restart's argv: exe, then each flag the command +// line set, as -name=value, in flag order. Every flag is a standard +// flag.Value, whose String parses back to the same value. +func reexecArgv(exe string, fs *flag.FlagSet) []string { + argv := []string{exe} + fs.Visit(func(f *flag.Flag) { + argv = append(argv, "-"+f.Name+"="+f.Value.String()) + }) + return argv +} diff --git a/cmd/xtcp2/reexec_handoff_test.go b/cmd/xtcp2/reexec_handoff_test.go new file mode 100644 index 000000000..1e836a81c --- /dev/null +++ b/cmd/xtcp2/reexec_handoff_test.go @@ -0,0 +1,474 @@ +package main + +import ( + "errors" + "flag" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/randomizedcoder/xtcp2/gen/go/xtcp_config" + "golang.org/x/sys/unix" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// The six secrets, each distinct so a leak names its field. +const ( + carriedS3 = "handoff-s3" + carriedS3ID = "handoff-s3-id" + carriedReadRaw = "handoff-read-raw" + carriedReadHMAC = "handoff-read-hmac" + carriedAdminRaw = "handoff-admin-raw" + carriedAdminHMAC = "handoff-admin-hmac" +) + +var carriedValues = []string{carriedS3, carriedS3ID, carriedReadRaw, carriedReadHMAC, carriedAdminRaw, carriedAdminHMAC} + +// TestSplitMergeSecrets: splitSecrets blanks exactly the secrets, and +// mergeSecrets puts them back so the exec'd image sees the config Set took. +func TestSplitMergeSecrets(t *testing.T) { + rawToken := xtcp_config.ListenerAuthMode_LISTENER_AUTH_MODE_RAW_TOKEN + tests := []struct { + category string + description string + cfg func() *xtcp_config.XtcpConfig + expected bool // secrets carried, so a memfd is needed + }{ + {"positive", "all six secrets", func() *xtcp_config.XtcpConfig { + c := sampleConfig() + c.S3SecretKey, c.S3AccessKey = carriedS3, carriedS3ID + c.ListenerAuth = &xtcp_config.ListenerAuth{Mode: rawToken, RawToken: carriedReadRaw, HmacSharedKey: carriedReadHMAC} + c.AdminListenerAuth = &xtcp_config.ListenerAuth{Mode: rawToken, RawToken: carriedAdminRaw, HmacSharedKey: carriedAdminHMAC} + return c + }, true}, + {"boundary", "no secrets at all", sampleConfig, false}, + {"corner", "S3 secrets with both auth blocks nil", func() *xtcp_config.XtcpConfig { + c := sampleConfig() + c.S3SecretKey = carriedS3 + return c + }, true}, + {"corner", "an auth block with a mode but no secrets keeps its mode", func() *xtcp_config.XtcpConfig { + c := sampleConfig() + c.ListenerAuth = &xtcp_config.ListenerAuth{Mode: rawToken} + return c + }, false}, + {"corner", "only the admin block's HMAC key", func() *xtcp_config.XtcpConfig { + c := sampleConfig() + c.AdminListenerAuth = &xtcp_config.ListenerAuth{Mode: rawToken, HmacSharedKey: carriedAdminHMAC} + return c + }, true}, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + t.Logf("%s: %s; expected secrets carried: %v", tc.category, tc.description, tc.expected) + cfg := tc.cfg() + orig := proto.Clone(cfg) + redacted, secrets := splitSecrets(cfg) + if !proto.Equal(cfg, orig) { + t.Error("splitSecrets changed its input") + } + js, err := protojson.Marshal(redacted) + if err != nil { + t.Fatal(err) + } + for _, s := range carriedValues { + if strings.Contains(string(js), s) { + t.Errorf("the redacted JSON holds %q", s) + } + } + if got := proto.Size(secrets) > 0; got != tc.expected { + t.Errorf("secrets carried = %v, want %v", got, tc.expected) + } + mergeSecrets(redacted, secrets) + if !proto.Equal(redacted, orig) { + t.Errorf("merged config differs from the original:\n got %v\nwant %v", redacted, orig) + } + }) + } +} + +// TestSecretsMemfd: the memfd holds the bytes, carries every seal, and is +// not close-on-exec, so the exec'd image inherits it. +func TestSecretsMemfd(t *testing.T) { + tests := []struct { + category string + description string + payload []byte + expected string + }{ + {"positive", "a secrets payload", []byte(`{"s3SecretKey":"x"}`), `{"s3SecretKey":"x"}`}, + {"boundary", "an empty payload is still sealed", nil, ""}, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + t.Logf("%s: %s; expected contents: %q", tc.category, tc.description, tc.expected) + f, fd, err := secretsMemfd(tc.payload) + if err != nil { + t.Fatal(err) + } + defer closeFile(t, f) + if int(f.Fd()) != fd { + t.Errorf("fd = %d, but the file's is %d", fd, f.Fd()) + } + b, err := os.ReadFile("/proc/self/fd/" + strconv.Itoa(fd)) + if err != nil { + t.Fatal(err) + } + if string(b) != tc.expected { + t.Errorf("contents %q, want %q", b, tc.expected) + } + seals, err := unix.FcntlInt(f.Fd(), unix.F_GET_SEALS, 0) + if err != nil { + t.Fatal(err) + } + if seals&reconfigureSecretSeals != reconfigureSecretSeals { + t.Errorf("seals %#x, want %#x", seals, reconfigureSecretSeals) + } + flags, err := unix.FcntlInt(f.Fd(), unix.F_GETFD, 0) + if err != nil { + t.Fatal(err) + } + if flags&unix.FD_CLOEXEC != 0 { + t.Error("the memfd is close-on-exec, so the exec'd image would not inherit it") + } + if _, err := f.WriteAt([]byte("y"), 0); !errors.Is(err, unix.EPERM) { + t.Errorf("write after sealing: %v, want EPERM", err) + } + }) + } +} + +// TestSecretsMemfdBound: the parent refuses a payload the exec'd image +// would refuse, so a soft restart that can't carry its secrets keeps the +// running process. +func TestSecretsMemfdBound(t *testing.T) { + tests := []struct { + category string + description string + size int + expected bool // refused + }{ + {"positive", "a small payload is carried", 64, false}, + {"boundary", "a payload at the bound is carried", reconfigureSecretsMaxBytes, false}, + {"boundary", "a payload one byte past the bound is refused", reconfigureSecretsMaxBytes + 1, true}, + {"negative", "a payload twice the bound is refused", 2 * reconfigureSecretsMaxBytes, true}, + {"corner", "an empty payload is carried", 0, false}, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + t.Logf("%s: %s; expected refused: %v", tc.category, tc.description, tc.expected) + f, fd, err := secretsMemfd(make([]byte, tc.size)) + if got := err != nil; got != tc.expected { + t.Fatalf("refused = %v (%v), want %v", got, err, tc.expected) + } + if tc.expected { + if !errors.Is(err, errReconfigureSecrets) || f != nil || fd != -1 { + t.Errorf("refusal: f=%v fd=%d err=%v, want nil, -1 and an errReconfigureSecrets", f, fd, err) + } + return + } + closeFile(t, f) + }) + } +} + +// TestSplitSecretsMatchesRedactSecrets fills every string field of a config +// and checks the two secret lists agree: whatever xtcp.RedactSecrets blanks +// is carried back by splitSecrets and mergeSecrets, and nothing carried as a +// secret is left in the redacted JSON that goes in the environment. +func TestSplitSecretsMatchesRedactSecrets(t *testing.T) { + tests := []struct { + category string + description string + cfg func() *xtcp_config.XtcpConfig + expected int // secret values carried + }{ + {"positive", "every string field set", func() *xtcp_config.XtcpConfig { + c := &xtcp_config.XtcpConfig{} + fillStrings(c.ProtoReflect(), "v", 0) + return c + }, 6}, + {"boundary", "an empty config carries nothing", func() *xtcp_config.XtcpConfig { return &xtcp_config.XtcpConfig{} }, 0}, + {"corner", "only the auth blocks filled", func() *xtcp_config.XtcpConfig { + c := &xtcp_config.XtcpConfig{ListenerAuth: &xtcp_config.ListenerAuth{}, AdminListenerAuth: &xtcp_config.ListenerAuth{}} + fillStrings(c.ListenerAuth.ProtoReflect(), "r", 0) + fillStrings(c.AdminListenerAuth.ProtoReflect(), "a", 0) + return c + }, 4}, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + t.Logf("%s: %s; expected secret values: %d", tc.category, tc.description, tc.expected) + cfg := tc.cfg() + orig := proto.Clone(cfg) + redacted, secrets := splitSecrets(cfg) + carried := stringValues(secrets.ProtoReflect()) + if len(carried) != tc.expected { + t.Errorf("carried %d secret values %q, want %d", len(carried), carried, tc.expected) + } + js, err := protojson.Marshal(redacted) + if err != nil { + t.Fatal(err) + } + for _, v := range carried { + if strings.Contains(string(js), `"`+v+`"`) { + t.Errorf("secret %q is carried but not redacted, so it would be in the environment", v) + } + } + mergeSecrets(redacted, secrets) + if !proto.Equal(redacted, orig) { + t.Errorf("RedactSecrets blanks a field splitSecrets doesn't carry:\n got %v\nwant %v", redacted, orig) + } + }) + } +} + +// fillStrings sets every singular string field of m, and of the messages +// under it, to a value naming the field. +func fillStrings(m protoreflect.Message, prefix string, depth int) { + if depth > 4 { + return + } + fields := m.Descriptor().Fields() + for i := range fields.Len() { + fd := fields.Get(i) + if fd.IsList() || fd.IsMap() || fd.ContainingOneof() != nil { + continue + } + switch fd.Kind() { + case protoreflect.StringKind: + m.Set(fd, protoreflect.ValueOfString(prefix+"-"+string(fd.FullName()))) + case protoreflect.MessageKind: + fillStrings(m.Mutable(fd).Message(), prefix, depth+1) + default: + } + } +} + +// stringValues lists the non-empty singular strings set in m and under it. +func stringValues(m protoreflect.Message) []string { + var out []string + m.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { + switch { + case fd.IsList() || fd.IsMap(): + case fd.Kind() == protoreflect.StringKind && v.String() != "": + out = append(out, v.String()) + case fd.Kind() == protoreflect.MessageKind: + out = append(out, stringValues(v.Message())...) + } + return true + }) + return out +} + +// TestLoadReconfigureSecrets: only a sealed memfd is read, and one that is +// read is closed and its key unset. +func TestLoadReconfigureSecrets(t *testing.T) { + // sealed returns a descriptor for a sealed memfd holding b, for + // loadReconfigureSecrets to take: a dup, so the test's own *os.File (and + // its finalizer) keeps the original. + sealed := func(t *testing.T, b string) string { + f, _, err := secretsMemfd([]byte(b)) + if err != nil { + t.Fatal(err) + } + defer closeFile(t, f) + fd, err := unix.Dup(int(f.Fd())) + if err != nil { + t.Fatal(err) + } + return strconv.Itoa(fd) + } + // owned returns f's descriptor; the test closes f. + owned := func(t *testing.T, f *os.File) string { + t.Cleanup(func() { closeFile(t, f) }) + return strconv.Itoa(int(f.Fd())) + } + // memfd returns a dup of a memfd holding b and carrying seals, for + // loadReconfigureSecrets to take and close. + memfd := func(t *testing.T, seals int, b string) string { + fd, err := unix.MemfdCreate("handoff-test", unix.MFD_ALLOW_SEALING|unix.MFD_CLOEXEC) + if err != nil { + t.Fatal(err) + } + f := os.NewFile(uintptr(fd), "handoff-test") + defer closeFile(t, f) + if _, err := f.WriteString(b); err != nil { + t.Fatal(err) + } + if seals != 0 { + if _, err := unix.FcntlInt(f.Fd(), unix.F_ADD_SEALS, seals); err != nil { + t.Fatal(err) + } + } + dup, err := unix.Dup(int(f.Fd())) + if err != nil { + t.Fatal(err) + } + return strconv.Itoa(dup) + } + tests := []struct { + category string + description string + value func(t *testing.T) string // "" leaves the key unset + closes bool // loadReconfigureSecrets takes the descriptor + wantErr bool + expected string // the S3 secret merged in, or the error's text + }{ + {"positive", "a sealed memfd with the secrets", func(t *testing.T) string { + return sealed(t, `{"s3SecretKey":"`+carriedS3+`"}`) + }, true, false, carriedS3}, + {"positive", "no key: a restart with no secrets", func(*testing.T) string { return "" }, false, false, ""}, + {"corner", "a sealed memfd holding an empty config merges nothing", func(t *testing.T) string { + return sealed(t, `{}`) + }, true, false, ""}, + {"negative", "a value that is not a number", func(*testing.T) string { return "three" }, false, true, "is not a descriptor"}, + {"negative", "a negative number", func(*testing.T) string { return "-4" }, false, true, "is not a descriptor"}, + {"boundary", "descriptor 2, stderr, is refused", func(*testing.T) string { return "2" }, false, true, "is not a descriptor"}, + {"boundary", "a number past 31 bits", func(*testing.T) string { return "2147483648" }, false, true, "is not a descriptor"}, + {"negative", "a pipe is not a memfd", func(t *testing.T) string { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { closeFile(t, w) }) + return owned(t, r) + }, false, true, "is not a memfd"}, + {"negative", "a regular file is refused", func(t *testing.T) string { + f, err := os.Create(filepath.Join(t.TempDir(), "secrets.json")) + if err != nil { + t.Fatal(err) + } + return owned(t, f) + }, false, true, "descriptor"}, + {"negative", "an unsealed memfd is refused and closed", func(t *testing.T) string { return memfd(t, 0, "") }, true, true, "is not sealed"}, + {"negative", "a memfd sealed against writes but not against unsealing is refused and closed", func(t *testing.T) string { + return memfd(t, unix.F_SEAL_SHRINK|unix.F_SEAL_GROW|unix.F_SEAL_WRITE, "") + }, true, true, "is not sealed"}, + {"negative", "a sealed memfd holding malformed JSON", func(t *testing.T) string { + return sealed(t, `{"s3SecretKey":`) + }, true, true, "reconfigure secrets"}, + {"boundary", "a sealed memfd one byte past the bound", func(t *testing.T) string { + return memfd(t, reconfigureSecretSeals, strings.Repeat(" ", reconfigureSecretsMaxBytes+1)) + }, true, true, "past"}, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + t.Logf("%s: %s; expected: %q", tc.category, tc.description, tc.expected) + v := tc.value(t) + if v == "" { + t.Setenv(reconfigureMemfdFDKey, "") + if err := os.Unsetenv(reconfigureMemfdFDKey); err != nil { + t.Fatal(err) + } + } else { + t.Setenv(reconfigureMemfdFDKey, v) + } + cfg := sampleConfig() + err := loadReconfigureSecrets(cfg) + if tc.closes { + fd, perr := strconv.Atoi(v) + if perr != nil { + t.Fatal(perr) + } + if _, ferr := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); !errors.Is(ferr, unix.EBADF) { + t.Errorf("descriptor %d still open after loading: %v", fd, ferr) + } + } + if tc.wantErr { + if err == nil || !errors.Is(err, errReconfigureSecrets) || !strings.Contains(err.Error(), tc.expected) { + t.Fatalf("err = %v, want an errReconfigureSecrets naming %q", err, tc.expected) + } + return + } + if err != nil { + t.Fatal(err) + } + if cfg.GetS3SecretKey() != tc.expected { + t.Errorf("S3SecretKey = %q, want %q", cfg.GetS3SecretKey(), tc.expected) + } + if _, set := os.LookupEnv(reconfigureMemfdFDKey); set { + t.Errorf("%s is still set after loading", reconfigureMemfdFDKey) + } + }) + } +} + +// TestLoadReconfigureConfig_secretsWithoutConfig: a descriptor key with no +// config to merge into is a broken carry, not a first boot. +func TestLoadReconfigureConfig_secretsWithoutConfig(t *testing.T) { + t.Setenv(reconfigureEnvKey, "") + t.Setenv(reconfigureMemfdFDKey, "7") + _, ok, err := loadReconfigureConfig() + if ok || !errors.Is(err, errReconfigureSecrets) { + t.Errorf("ok=%v err=%v, want an errReconfigureSecrets", ok, err) + } +} + +// TestReexecArgv: argv carries each flag the command line set, as +// -name=value, and parses back to the same values. +func TestReexecArgv(t *testing.T) { + newFlags := func() *flag.FlagSet { + fs := flag.NewFlagSet("xtcp2", flag.ContinueOnError) + fs.String("dest", "null", "") + fs.Bool("enabled", false, "") + fs.Int("netlinkers", 4, "") + fs.Duration("pollFrequency", 10*time.Second, "") + return fs + } + tests := []struct { + category string + description string + args []string + expected []string + }{ + {"boundary", "no flags set", nil, []string{"/bin/xtcp2"}}, + {"positive", "several flags, in name order", []string{"-netlinkers", "8", "-dest", "kafka:127.0.0.1:9092", "-enabled"}, + []string{"/bin/xtcp2", "-dest=kafka:127.0.0.1:9092", "-enabled=true", "-netlinkers=8"}}, + {"corner", "a value with spaces and '=' stays one argument", []string{"-dest=a b=c"}, + []string{"/bin/xtcp2", "-dest=a b=c"}}, + {"corner", "a bool set false is carried as false", []string{"-enabled=false"}, + []string{"/bin/xtcp2", "-enabled=false"}}, + {"corner", "a flag set to its default is still carried", []string{"-netlinkers=4"}, + []string{"/bin/xtcp2", "-netlinkers=4"}}, + {"positive", "a duration in its canonical form", []string{"-pollFrequency=90s"}, + []string{"/bin/xtcp2", "-pollFrequency=1m30s"}}, + {"negative", "arguments after the flags are not carried", []string{"-netlinkers=2", "stray"}, + []string{"/bin/xtcp2", "-netlinkers=2"}}, + } + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + t.Logf("%s: %s; expected: %q", tc.category, tc.description, tc.expected) + fs := newFlags() + if err := fs.Parse(tc.args); err != nil { + t.Fatal(err) + } + got := reexecArgv("/bin/xtcp2", fs) + if !slices.Equal(got, tc.expected) { + t.Fatalf("argv %q, want %q", got, tc.expected) + } + back := newFlags() + if err := back.Parse(got[1:]); err != nil { + t.Fatalf("argv does not parse back: %v", err) + } + fs.VisitAll(func(f *flag.Flag) { + if b := back.Lookup(f.Name).Value.String(); b != f.Value.String() { + t.Errorf("-%s parsed back as %q, want %q", f.Name, b, f.Value.String()) + } + }) + }) + } +} + +func closeFile(t *testing.T, f *os.File) { + t.Helper() + if err := f.Close(); err != nil { + t.Error(err) + } +} diff --git a/cmd/xtcp2/reexec_process_test.go b/cmd/xtcp2/reexec_process_test.go index eceaf0674..0b8238196 100644 --- a/cmd/xtcp2/reexec_process_test.go +++ b/cmd/xtcp2/reexec_process_test.go @@ -12,6 +12,7 @@ import ( "os/exec" "runtime/pprof" "slices" + "strings" "sync" "syscall" "testing" @@ -65,7 +66,7 @@ func TestFinishMain(t *testing.T) { // TestReexecProcess runs a soft restart for real in a child copy of the test // binary: runMain with a stubbed daemon that requests one, then finishMain's // syscall.Exec into the same test, which prints the tag it was carried and -// whether it inherited an armed ITIMER_PROF. With a CPU profile on +// whether it inherited an armed ITIMER_PROF and the config's secrets. With a CPU profile on // (-profile.mode=cpu, or the Pyroscope agent) the timer is off in the new // image only because finishMain runs after runMain's deferred profiler stops. // An armed timer is what the s3parquet-stress VM tripped over: SIGPROF, back @@ -87,13 +88,15 @@ func TestReexecProcess(t *testing.T) { orKilledBy os.Signal }{ {"no_profile", "positive", "soft restart with no profiler", "the exec'd image loads the carried config; no timer", - "none", "reexec tag=REEXEC-none itimer=off", nil}, + "none", "reexec tag=REEXEC-none itimer=off secrets=none", nil}, + {"secrets", "positive", "soft restart of a config with an S3 secret and a listener token", "the secrets arrive through the memfd, absent from the environment", + "secrets", "reexec tag=REEXEC-secrets itimer=off secrets=memfd", nil}, {"cpu_profile", "positive", "soft restart with -profile.mode=cpu", "the profile stops first; the exec'd image loads the carried config with no timer", - "cpu", "reexec tag=REEXEC-cpu itimer=off", nil}, + "cpu", "reexec tag=REEXEC-cpu itimer=off secrets=none", nil}, {"pyroscope", "corner", "soft restart with the Pyroscope agent on and its server unreachable", "the agent stops first; the exec'd image loads the carried config with no timer", - "pyroscope", "reexec tag=REEXEC-pyroscope itimer=off", nil}, + "pyroscope", "reexec tag=REEXEC-pyroscope itimer=off secrets=none", nil}, {"exec_during_profile", "negative", "syscall.Exec while a CPU profile is running", "the exec'd image inherits an armed timer, or SIGPROF kills it first", - "exec_during_profile", "reexec tag=REEXEC-exec_during_profile itimer=armed", syscall.SIGPROF}, + "exec_during_profile", "reexec tag=REEXEC-exec_during_profile itimer=armed secrets=none", syscall.SIGPROF}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -105,7 +108,8 @@ func TestReexecProcess(t *testing.T) { t.Fatal(err) } child := exec.CommandContext(ctx, binary, "-test.run=^TestReexecProcess$") - child.Env = append(envWithoutKey(os.Environ(), reconfigureEnvKey), "XTCP2_REEXEC_HELPER="+tc.mode) + child.Env = append(envWithoutKey(envWithoutKey(os.Environ(), reconfigureEnvKey), reconfigureMemfdFDKey), + "XTCP2_REEXEC_HELPER="+tc.mode) // -profile.mode=cpu writes cpu.pprof to the working directory. child.Dir = t.TempDir() var stdout, stderr bytes.Buffer @@ -132,10 +136,16 @@ func TestReexecProcess(t *testing.T) { } } +// reexecCarriedS3 is the "secrets" mode's S3 secret; its listener token is +// the same with "-token" appended. +const reexecCarriedS3 = "REEXEC-s3" + // runReexecChild is the child side of TestReexecProcess. The first image // requests a soft restart; the image it exec's into finds the carried config // and prints its tag. func runReexecChild(mode string) error { + // Read before loadReconfigureConfig, which unsets the descriptor's key. + carriedJSON := os.Getenv(reconfigureEnvKey) carried, ok, err := loadReconfigureConfig() if err != nil { return err @@ -149,10 +159,14 @@ func runReexecChild(mode string) error { if it.Value != (unix.Timeval{}) || it.Interval != (unix.Timeval{}) { timer = "armed" } - fmt.Printf("reexec tag=%s itimer=%s\n", carried.GetTag(), timer) + fmt.Printf("reexec tag=%s itimer=%s secrets=%s\n", carried.GetTag(), timer, carriedSecrets(carried, carriedJSON)) return nil } newCfg := &xtcp_config.XtcpConfig{Tag: "REEXEC-" + mode} + if mode == "secrets" { + newCfg.S3SecretKey = reexecCarriedS3 + newCfg.ListenerAuth = &xtcp_config.ListenerAuth{RawToken: reexecCarriedS3 + "-token"} + } if mode == "exec_during_profile" { if err := pprof.StartCPUProfile(io.Discard); err != nil { @@ -165,7 +179,7 @@ func runReexecChild(mode string) error { args := []string{"xtcp2", "-dest", "null"} switch mode { - case "none": + case "none", "secrets": case "cpu": args = append(args, "-profile.mode=cpu") case "pyroscope": @@ -173,7 +187,7 @@ func runReexecChild(mode string) error { default: return fmt.Errorf("unknown re-exec helper mode %q", mode) } - testArgs := os.Args + testArgs, testFlags := os.Args, flag.CommandLine os.Args = args flag.CommandLine = flag.NewFlagSet(args[0], flag.ContinueOnError) promHandlerStarter = func(context.Context, *sync.WaitGroup, promParams) error { return nil } @@ -185,12 +199,31 @@ func runReexecChild(mode string) error { } code := runMain(context.Background()) // The exec'd image is this test binary again, so it needs the test's - // own arguments, not the xtcp2 flags runMain parsed. + // own flags, not the xtcp2 ones runMain parsed: execSelfWithConfig + // rebuilds argv from flag.CommandLine. os.Args = testArgs + flag.CommandLine = testFlags code = finishMain(code, execSelfWithConfig) return fmt.Errorf("finishMain returned %d without re-exec'ing", code) } +// carriedSecrets describes how the exec'd image got the secrets: "none" when +// the config had none, "memfd" when both arrived and neither the config's +// JSON nor the environment holds them, and what went wrong otherwise. +func carriedSecrets(c *xtcp_config.XtcpConfig, carriedJSON string) string { + switch { + case c.GetS3SecretKey() == "" && c.GetListenerAuth().GetRawToken() == "": + return "none" + case c.GetS3SecretKey() != reexecCarriedS3 || c.GetListenerAuth().GetRawToken() != reexecCarriedS3+"-token": + return "wrong" + case strings.Contains(carriedJSON, reexecCarriedS3): + return "in-environment-json" + case os.Getenv(reconfigureMemfdFDKey) != "": + return "descriptor-key-left-set" + } + return "memfd" +} + // killedBy reports whether err is an exit by signal sig. func killedBy(err error, sig os.Signal) bool { var exitErr *exec.ExitError diff --git a/cmd/xtcp2/xtcp2.go b/cmd/xtcp2/xtcp2.go index 10b37978a..eb50a07fd 100644 --- a/cmd/xtcp2/xtcp2.go +++ b/cmd/xtcp2/xtcp2.go @@ -1193,47 +1193,81 @@ func runDaemonDefault(ctx context.Context, cancel context.CancelFunc, c *xtcp_co } // loadReconfigureConfig returns the full XtcpConfig carried across a -// soft-restart re-exec via the reconfigureEnvKey environment variable. ok is -// false when the variable is unset/empty (normal first boot). A non-nil error -// means the variable was present but unparseable. +// soft-restart re-exec: the config from the reconfigureEnvKey environment +// variable, and its secrets from the memfd reconfigureMemfdFDKey names (see +// reexec_handoff.go). ok is false when the variable is unset/empty (normal +// first boot). A non-nil error means the carry was present but unusable. func loadReconfigureConfig() (c *xtcp_config.XtcpConfig, ok bool, err error) { v, present := os.LookupEnv(reconfigureEnvKey) if !present || v == "" { + if fd, set := os.LookupEnv(reconfigureMemfdFDKey); set && fd != "" { + return nil, false, fmt.Errorf("%w: %s is set without %s", errReconfigureSecrets, + reconfigureMemfdFDKey, reconfigureEnvKey) + } return nil, false, nil } c = &xtcp_config.XtcpConfig{} if err := protojson.Unmarshal([]byte(v), c); err != nil { return nil, false, err } + if err := loadReconfigureSecrets(c); err != nil { + return nil, false, err + } return c, true, nil } // execSelfWithConfig re-execs this binary in place (same PID/container), -// carrying newCfg as protojson in reconfigureEnvKey so the fresh process boots -// with it. os.Args is preserved, so all process-level flags carry unchanged; -// only the XtcpConfig is overridden at startup. On success syscall.Exec does -// not return; on failure it logs and returns so the caller exits normally. +// carrying newCfg so the fresh process boots with it: the config, secrets +// blanked, as protojson in reconfigureEnvKey, and the secrets in a sealed +// memfd (reexec_handoff.go). argv is rebuilt from the flags the command line +// set, so process-level flags carry unchanged; only the XtcpConfig is +// overridden at startup. On success syscall.Exec does not return; on failure +// it logs and returns so the caller exits normally. func execSelfWithConfig(newCfg *xtcp_config.XtcpConfig) { exe, err := os.Executable() if err != nil { log.Printf("reconfigure: os.Executable failed, cannot re-exec: %v", err) return } - js, err := protojson.Marshal(newCfg) + redacted, secrets := splitSecrets(newCfg) + js, err := protojson.Marshal(redacted) if err != nil { log.Printf("reconfigure: protojson.Marshal failed, cannot re-exec: %v", err) return } - env := append(envWithoutKey(os.Environ(), reconfigureEnvKey), reconfigureEnvKey+"="+string(js)) - log.Printf("reconfigure: re-exec %s (soft restart, config %d bytes)", exe, len(js)) - // G702 is a false positive here: this is an intentional in-place restart of - // THIS binary (os.Executable), preserving our own os.Args and environment - // plus a config we validated and serialized ourselves — not execution of an - // external, attacker-chosen command. The new config was protovalidate'd in - // the Set handler before reaching this path. - if err := syscall.Exec(exe, os.Args, env); err != nil { //nolint:gosec // G702: re-exec of self with validated config; see comment above + // ConfigService.Set refuses a config past this, so reaching it means a + // caller skipped Set; execve would fail with E2BIG. + if len(js) > xtcp.ReconfigureMaxConfigBytes { + log.Printf("reconfigure: config is %d bytes, past %d, cannot re-exec", len(js), xtcp.ReconfigureMaxConfigBytes) + return + } + env := envWithoutKey(envWithoutKey(os.Environ(), reconfigureEnvKey), reconfigureMemfdFDKey) + env = append(env, reconfigureEnvKey+"="+string(js)) + var carried *os.File + if proto.Size(secrets) > 0 { + sb, err := protojson.Marshal(secrets) + if err != nil { + log.Printf("reconfigure: protojson.Marshal of the secrets failed, cannot re-exec: %v", err) + return + } + f, fd, err := secretsMemfd(sb) + if err != nil { + log.Printf("reconfigure: cannot carry the secrets, cannot re-exec: %v", err) + return + } + carried = f + env = append(env, reconfigureMemfdFDKey+"="+strconv.Itoa(fd)) + } + argv := reexecArgv(exe, flag.CommandLine) + log.Printf("reconfigure: re-exec %s (soft restart, config %d bytes, %d flags)", exe, len(js), len(argv)-1) + if err := syscall.Exec(exe, argv, env); err != nil { log.Printf("reconfigure: syscall.Exec failed: %v", err) } + if carried != nil { + if err := carried.Close(); err != nil { + log.Printf("reconfigure: closing the secrets memfd: %v", err) + } + } } // envWithoutKey returns env with any existing KEY=... entries for key removed, diff --git a/docs/netlink/README.md b/docs/netlink/README.md index 015e8c27f..eb9a4f5b4 100644 --- a/docs/netlink/README.md +++ b/docs/netlink/README.md @@ -23,6 +23,8 @@ If you are looking for the daemon as a whole, start at the | [Address write path — status](addr-write-path-status.md) | The tracker for that plan: phase and task states, the four Phase 0 measurements, which task amends each flipped statement, and the verification ledger. | | [goip parity: remaining gaps](goip-parity-gaps.md) | What full `ip` coverage still needs: the 20 objects goip does not run, the missing verbs on the 11 it does, the generic netlink and XFRM families, and the 23 ungated rows, 22 of which G0 has gated. For each netlink message type `ip` uses, a measured count of nlmon pcaps, table-driven parse tests and parse benchmarks. The plan to close the gaps runs in three tiers: complete the capture corpus, then the parsers, then goip and the matrix. | | [goip parity gaps — status](goip-parity-gaps-status.md) | The tracker for that plan: the next action, the state of each phase from C0 to G7, the task checklist, the verification ledger and the decision log. A phase is closed, and the assessment re-measured, in its last task PR. | +| [goip parity: link kinds](goip-link-kinds.md) | The plan after the gaps plan: every link kind a VM can create (bridge, the VLAN family, the UDP overlays, the IP tunnels and the rest), parsed, encoded and `-d`-rendered by goip, plus iproute2 7.2.0's `ip link set dev X inet …` devconf write. The per-kind inventory at `v7.2.0`, the exclusions, and the same three tiers: captures, parsers, then goip and the matrix. | +| [goip parity: link kinds — status](goip-link-kinds-status.md) | The tracker for that plan: the next action, the state of each phase from KC0 to KG5, the task checklist, the verification ledger and the decision log. | | [vrf show NAME](vrf-show-name.md) | **Landed.** `goip vrf show NAME`, the refused `ipvrf_get_table` single lookup — the one netlink path left on the `vrf` object. Five tasks (VS0–VS4), each with its test table: pin the already-committed request bytes, the `VrfTableByName` accessor, the argv and output, the capture rows, and the three matrix rows — gated. | | [Upstream bug: bond partner state width](upstream/iproute2-bond-partner-state-width.md) | An iproute2 bug found while designing the link write path: `ip` reads the u16 `IFLA_BOND_SLAVE_AD_PARTNER_OPER_PORT_STATE` as a u8, so big-endian hosts always print `0 <>`. | diff --git a/docs/netlink/coverage-status.md b/docs/netlink/coverage-status.md index 10466c55d..7159ae2bf 100644 --- a/docs/netlink/coverage-status.md +++ b/docs/netlink/coverage-status.md @@ -524,7 +524,7 @@ that file only in a comment about what a solicited reply carries. |---|---|---|---| | `prefixmsg` | `rtnetlink.h` | `RTM_NEWPREFIX` | `ip monitor prefix` | | `nduseroptmsg` | `rtnetlink.h` | `RTM_NEWNDUSEROPT` | `ip monitor` RA options | -| `br_port_msg` | `if_bridge.h` | `RTM_{NEW,DEL,GET}MDB` | `bridge mdb` — not an `ip` command | +| `br_port_msg` | `if_bridge.h` | `RTM_{NEW,DEL,GET}MDB` | `bridge mdb` — not an `ip` command (decoded since W5b, `xtcpnl_mdb.go`) | | `tcmsg` | `rtnetlink.h` | `RTM_{NEW,DEL,GET}{QDISC,TCLASS,TFILTER}` | `tc` — not an `ip` command | | `tcamsg` | `rtnetlink.h` | `RTM_{NEW,DEL,GET}ACTION` | `tc actions` — not an `ip` command | @@ -687,6 +687,12 @@ each — `xtcpnl_inet_diag_tcpinfo.go`, `_bbrinfo`, `_dctcpinfo`, `_vegasinfo`, The bytecode opcodes are a filter language the daemon does not use — it dumps and filters in Go — so their absence is a scope boundary, not a gap. +Since W5c the request side is decoded too, for the workbench's `ss` rows. +`xtcpnl_inet_diag_bc.go` names the bytecode opcodes and the `INET_DIAG_REQ_*` +attributes. It parses a request, its bytecode and its protocol attribute, and +it ports the kernel's audit and run (see "W5c: `ss`"). The 17 absences above +are counted from before W5c. + The same caveat as above applies to the word *referenced*: this is an enum-level diff, and the per-field proof for the biggest of these structs is a different mechanism entirely — the layout oracle in @@ -975,6 +981,11 @@ recollection. - [Baseline, measured 2026-09-25](#baseline-measured-2026-09-25) - [Phase status](#phase-status) - [Capture readiness](#capture-readiness) + - [Workbench and generated rows](#workbench-and-generated-rows) + - [tc from tdc (W1)](#tc-from-tdc-w1) + - [Grammar matrix (W2)](#grammar-matrix-w2) + - [Captures an outside consumer needs: BIRD's netlink parser](#captures-an-outside-consumer-needs-birds-netlink-parser) + - [Plan: B1–B3, the captures, their parsers and the completeness test](#plan-b1b3-the-captures-their-parsers-and-the-completeness-test) - [What has landed](#what-has-landed) - [Phase exit criteria](#phase-exit-criteria) - [Phase 0](#phase-0) @@ -1016,7 +1027,7 @@ Phases and scope are as defined in | **0** | Reflection removal (0a), AccECN (0b), layout oracle (0c), perf gate (0d), upstream pin guard (0e), then core wire export, subpackage skeleton, capture generalization | **partial** | 0a–0e — see [Phase 0](#phase-0). Plus the rtnetlink-only capture flavor that Phase 0 generalizes, and the five core wire primitives, exported in [`b4428c7`](#b4428c7--the-five-core-wire-primitives-are-exported) | Gating the other 17 protocols that currently report deltas, one per phase as each is triaged; package still flat; BPF filter still pins family 0. `pkg/nsdiscover/nsid.go` no longer hand-rolls its own wire layer — it went through `xtcpnl.NewAttrBuilder`/`BuildRequest`/`WalkNlMsgs`/`WalkRTAttrs` in [`b4428c7`](#b4428c7--the-five-core-wire-primitives-are-exported), closing `TODO-SOON.md` §15 | | **1** | Multicast listener, `BuildDumpNeighRequest`, in-guest smoke check | **done** | Event parsing layer, `ndmsg` decoder, `ParseNeigh`, real captured event fixtures, and `BuildDumpNeighRequest` as of [`1beb2b8`](#1beb2b8--the-per-family-request-builders-and-the-single-get-primitive). **The listener**: `pkg/rtnlmon` on `pkg/nlconn` (linkmonitor's transport, moved out of `internal/`), `xtcpnl.WalkNotifications`, the `ENOBUFS` resync that drains before it re-dumps, and self-test check 20 (`XTCP2_SELF_TEST_RTNL_EVENTS`) — see [Phase 1: the listener](#phase-1-the-listener). **The consumer**: `-localityEvents` keeps locality snapshots current from it (`localnet.Tracker`), self-test `XTCP2_SELF_TEST_LOCALITY_EVENTS` | — | | **2** | `IFA_CACHEINFO`/`IFA_FLAGS`, `IFLA_ADDRESS`/`IFLA_STATS64`, `RTA_EXPIRES`/`RTA_CACHEINFO`/`RTA_METRICS`, orphaned `INET_DIAG_PRAGUEINFO` | **partial** | `IFA_CACHEINFO` (with `struct ifa_cacheinfo` and the two lifetime predicates), `IFA_FLAGS` (replacing the u8 header field, not extending it), `IFLA_ADDRESS`/`IFLA_BROADCAST` and nine more `IFLA_*`, as of [`4494b42`](#4494b42--the-ifla_-and-ifa_-attribute-decoders-a-show-line-needs). `IFLA_STATS`/`IFLA_STATS64` are **done**: decoded in `xtcpnl_link_stats.go`, rendered by `render.LinkStatsText`, and driven by `ip -s link show` as the tenth parity command. They were previously recorded here as out of scope on a premise that was wrong — see [below](#what-ifla_stats64-was-actually-blocked-on) | the `RTA_EXPIRES` *attribute* (distinct from `rta_cacheinfo`'s `rta_expires` member; `print_route` never reads it, so nothing on a `show` path needs it), and `INET_DIAG_PRAGUEINFO`. **`RTA_METRICS` is done**, with real fixtures: the gated capture topology carries an `mtu 1400 advmss 1300` route, so `dumps/netlink_route_getroute.pcap` has one and `RouteMetrics` decodes it. **`RTA_CACHEINFO` is done** too, in `xtcpnl_rta_cacheinfo.go` — and its real fixtures (48 of 74 captured routes) turn out to be all-zero by kernel construction, which is a finding in itself: see [below](#-s-on-route-neigh-and-rule-two-no-ops-and-a-bug-in-the-ungated-form) | -| **3** | Rules (`FRA_*`), nexthop (`NHA_*`), bridge/VLAN, `IFLA_LINKINFO` descent | **partial** | the `IFLA_LINKINFO` descent, as far as `IFLA_INFO_KIND` — [`4494b42`](#4494b42--the-ifla_-and-ifa_-attribute-decoders-a-show-line-needs), the first production caller of `WalkRTAttrsNested`. **`FRA_*` is done**: `struct fib_rule_hdr` and all 30 attributes decode in `xtcpnl_fib_rule_hdr.go`, `render.RuleView` prints them, and `ip rule show` drives four parity commands — see [below](#ip-rule-show-the-object-with-no-name-table). **`NHA_*` is done**: `struct nhmsg` and the nexthop attributes — id, oif, gateway, blackhole, fdb, group, group-type and the nested `NHA_RES_GROUP` — decode in `xtcpnl_nhmsg.go` and render for `ip nexthop show` and `-d` route nhid resolution; see the nexthop arc in [Where we are](#where-we-are). **The `IFLA_INFO_DATA` sub-nest is decoded for kind `vrf`** (`IFLA_VRF_TABLE`), together with `IFLA_INFO_SLAVE_DATA`'s `IFLA_VRF_PORT_TABLE`, driving `-d link`/`addr show` of a VRF master and its port. **And for kind `bond`**: every `IFLA_BOND_*` option, the `AD_INFO` nest and the twelve `IFLA_BOND_SLAVE_*` port attributes decode (link-write-path phase 1). They are not rendered yet, so `-d` on a bond still refuses | bridge/VLAN, the bond `-d` render, and the `IFLA_INFO_DATA` sub-nest for link kinds other than `vrf` and `bond`, each a separate attribute space per kind | +| **3** | Rules (`FRA_*`), nexthop (`NHA_*`), bridge/VLAN, `IFLA_LINKINFO` descent | **partial** | the `IFLA_LINKINFO` descent, as far as `IFLA_INFO_KIND` — [`4494b42`](#4494b42--the-ifla_-and-ifa_-attribute-decoders-a-show-line-needs), the first production caller of `WalkRTAttrsNested`. **`FRA_*` is done**: `struct fib_rule_hdr` and all 30 attributes decode in `xtcpnl_fib_rule_hdr.go`, `render.RuleView` prints them, and `ip rule show` drives four parity commands — see [below](#ip-rule-show-the-object-with-no-name-table). **`NHA_*` is done**: `struct nhmsg` and the nexthop attributes — id, oif, gateway, blackhole, fdb, group, group-type and the nested `NHA_RES_GROUP` — decode in `xtcpnl_nhmsg.go` and render for `ip nexthop show` and `-d` route nhid resolution; see the nexthop arc in [Where we are](#where-we-are). **The `IFLA_INFO_DATA` sub-nest is decoded for kind `vrf`** (`IFLA_VRF_TABLE`), together with `IFLA_INFO_SLAVE_DATA`'s `IFLA_VRF_PORT_TABLE`, driving `-d link`/`addr show` of a VRF master and its port. **And for kind `bond`**: every `IFLA_BOND_*` option, the `AD_INFO` nest and the twelve `IFLA_BOND_SLAVE_*` port attributes decode (link-write-path phase 1). They are not rendered yet, so `-d` on a bond still refuses. **The link-kinds plan's parsers** ([goip-link-kinds.md](goip-link-kinds.md), KP1 to KP5) decode the bridge, VLAN-family, UDP-overlay, IP-tunnel, hsr and netkit kinds' sub-nests too, also unrendered until its KG phases; vxcan and batadv send no sub-nest to decode | bridge/VLAN, the bond `-d` render, and the `-d` render of every decoded kind | | **4** | tc telemetry only (23 top-level `TCA_*`) | not started | — | all of it | | **5** | genetlink: `nlctrl` `GETFAMILY` first, then ethtool/devlink/netdev/vdpa/fou/gtp | not started | — | all of it | | **6** | xfrm, conntrack, ipset, proc_event, rdma | **partial** | **rdma**: the hand-declared `RDMA_NL*` block (`xtcpnl_rdma_consts.go`, every value C-gated against ``), read-only nldev request builders, and decoders for the device, port, resource summary, per-object `RES_*`, hardware counter, system and monitor-event replies, on a strace corpus, which `pkg/linkmonitor/internal/linuxio` now decodes with. **Rendered by `gordma`**, a read-only `rdma` clone that replays every corpus row on all six kernels byte for byte — see [below](#gordma-a-read-only-rdma) | xfrm, conntrack, ipset, proc_event; for rdma, live parity against `rdma` in a VM | @@ -1036,12 +1047,12 @@ is `ip -t monitor all label` (`:1757`). | Phase / family | Kernel module | Subscriber | Guest tooling | Ready? | |---|---|---|---|---| -| 1–3 link, addr, route, neigh, rule, nexthop | `veth`, `dummy` ✅ | `ip monitor all` ✅ | `iproute2` ✅ | **yes** | -| 3 bridge / VLAN | `bridge` — **add** | `ip monitor all` ✅ | `bridge vlan` (in `iproute2`) ✅ | no | +| 1–3 link, addr, route, neigh, rule, nexthop | `veth`, `dummy` ✅ | `ip monitor all` ✅ | `iproute2` ✅ | **yes**; `ip route`, `ip rule` and `ip nexthop` also in the workbench (`ws/route4/`, `ws/route6/`, `ws/rule4/`, `ws/rule6/`, `ws/nh4/`, `ws/nh6/`, `ws/nhgrp/`; 343 + 406 + 280 generated rows); see [the grammar matrix](#grammar-matrix-w2) | +| 3 bridge / VLAN | `bridge` ✅, loaded by the link-write capture | `ip monitor all` ✅ (FDB); `bridge monitor vlan` under `xtcp2-nl-brmon` ✅ (VLAN) | `bridge` (in `iproute2`) ✅ | **yes**, in the link-write capture (`link-write/brmpls/`), not this flavor; see [the BIRD plan](#plan-b1b3-the-captures-their-parsers-and-the-completeness-test) | | link-write 0–3 bond / LACP, Tier 1 | `bonding` — **add**, with `options bonding max_bonds=0` (module load creates `bond0` in `init_net`, `drivers/net/bonding/bond_main.c:6629-6630`) | n/a (dump-only readback) | `iproute2` ✅; a **partner** bond in a second netns over veth — **add** | no | | link-write 8 bond / LACP, Tier 2 | `bonding` — **add** | n/a | a second microVM and two point-to-point virtio-net links with `speed=10000,duplex=full` — **add**; never a bridge (`BR_GROUPFWD_RESTRICTED` drops LACPDUs) | no | -| 4 tc telemetry | `sch_netem` or `sch_htb` — **add** | n/a (dump-only) | `tc` (in `iproute2`) ✅ | no | -| 5 genetlink: ethtool | kernel config — **verify** | n/a (dump-only) | `ethtool` — **add** | no | +| 4 tc telemetry | every `sch_*`, `cls_*` and `act_*` module, loaded per rows file by the workbench's batch runner ✅ | n/a (dump-only) | `tc` (in `iproute2`) ✅ | **yes**, in the workbench (`ws/tdc/`, 1310 rows from tdc), not this flavor; see [tc from tdc](#tc-from-tdc-w1) | +| 5 genetlink: ethtool | `netdevsim`, loaded per rows file by the workbench's batch runner ✅ | n/a (dump-only) | `ethtool` 7.1 in the workbench ✅ | **yes**, in the workbench (`ws/ethring/`, `ws/ethchan/`, `ws/ethpause/`, `ws/ethfec/`, `ws/ethcoal/`, `ws/ethshow/`; 244 generated rows), not this flavor; see [W5d](#w5d-ethtool) | | 5 genetlink: devlink, netdev | kernel config — **verify** | n/a (dump-only) | `devlink` (in `iproute2`) ✅ | no | | 6 xfrm | `xfrm_user` and the ESP modules, loaded late by the link-write capture ([C0-T02](goip-parity-gaps.md#c0-t02-late-modules-sysctls-and-an-event-subscriber)) ✅ | `ip xfrm monitor` under `xtcp2-nl-xfrmmon` ([C3-T02](goip-parity-gaps.md#c3-t02-xfrm-policy-and-monitor)) ✅ | `iproute2` ✅ | **yes**, in the link-write capture (`link-write/xfrm/`), not this flavor | | 6 conntrack | `nf_conntrack_netlink` — **add** | `conntrack -E` — **add** | `conntrack-tools` — **add** | no | @@ -1076,6 +1087,2091 @@ and the header of `nix/capture-netlink-fixtures.nix`. The rows above still apply to the *notification* families, which need a subscriber and a module in the guest. +### Workbench and generated rows + +A hand-written `*-corpus.exp` row costs a VM rebuild and a ~500 s cold boot +per attempt, so the corpus grew by dozens of rows when it needs thousands. The +`nlcap-workbench` flavor removes both costs. It is a VM you keep up and drive +over ssh, and the rows it runs are data (JSON Lines), not Tcl: + +```sh +nix run .#nlcap-ws -- up # boot, wait for ssh +nix run .#nlcap-ws -- run --slug t/fq -- tc qdisc add dev lo root fq +nix run .#nlcap-ws -- batch nix/microvms/workbench/selftest.rows.jsonl -j 8 +nix run .#nlcap-ws -- down # power off, delete the key +``` + +- **Nothing in the repo rebuilds it.** The guest has no `xtcp2AllPackage`. The + capture helper (`netlink-capture.nix`) and the batch runner + (`workbench/nlcap_batch.py`) run by store path through the guest's 9p + `/nix/store`, so a new build of either is picked up by the next call, with + no reboot. Measured: after a Go test file and its testdata were staged, the + rebuilt CLI pointed at the same VM runner and helper store paths. Only the + batch runner changed, because its own source had been edited. +- **Ephemeral ssh key.** `up` makes an ed25519 pair under + `$XDG_STATE_HOME/xtcp2-nlcap-ws/`. qemu hands the public half to the guest + over `-fw_cfg`, and a oneshot installs it before sshd starts. sshd listens + on `127.0.0.1:12061` only, and `down` deletes the key. The workbench owns + ports 12059–12061 (`constants.nix` `nlcapWs`), so it can stay up while + other sessions boot their VMs on 12055/12056. +- **Parallel rows.** Netlink taps are per network namespace, so each row gets + a fresh netns (`ws`) with its own `nlmon0`, and `-j N` rows run at + once without seeing each other's messages. A row marked `serial` runs alone. +- **`cache=loose` on the 9p store** is what makes this fast. With microvm.nix's + default (no cache), every exec from `/nix/store` took ~0.4 s and one row took + 27 s. With `cache=loose`: + + | measure | value | + |---|---| + | cold `up` to ssh | 28 s (98 s without `cache=loose`) | + | `ip -V` in the guest | 0.06 s | + | one row, alone | ~2.5 s | + | 40 rows, `-j 8` on 6 vCPUs | 23.5 s, 0.59 s a row (~38 s at `-j 4`) | + | the 6 selftest rows, `-j 8` | 7 s | + +**A row** is one JSON object per line: `id` (also the output directory under +`testdata//ws/`), `desc` (starts with `positive:`, `negative:`, +`boundary:` or `corner:`), `cmd`, and optionally `pre` (setup outside the +capture window, each step a command or `[command, rc]`), `readback` (named +dumps captured after the command), `proto`, `floor`, `modules`, `serial`, +`axes` and `source`, plus `expect`: + +| `expect` field | checked by `TestWsCorpusRows` against the pcap | +|---|---| +| `rc` | the command's exit status | +| `sent` | how many requests the command sent | +| `errno`, `extack` | the kernel's `NLMSG_ERROR` and its extack message | +| `refused_by` | `none`, `kernel`, `iproute2` (rc ≠ 0 and **zero** requests sent) or `ack-then-fail` | +| `match` | `{readback, pattern, count}`: the RE2 pattern matches `count` times in the readback's stdout (tdc's verify check) | + +Each run writes `cmd.json` beside the pcaps: the row, the guest's kernel and +iproute2 versions, and the result. `pkg/xtcpnl/xtcpnl_ws_corpus_test.go` turns +every `cmd.json` into a subtest, and `TestWsRowProblem` checks the oracle +itself on hand-built observations, including the ones that must fail. + +**What the selftest rows already showed.** Three of the six expectations +first written down were wrong, and the oracle caught each one: + +- `ip link add <16-byte name> type dummy` is **not** refused by iproute2. The + positional name is checked against `ALTIFNAMSIZ` (iproute2 `afd67550`, "allow + to use alternative names as handle"), so the request reaches the kernel. + There it fails `IFLA_IFNAME`'s `NLA_STRING` policy (`len = IFNAMSIZ - 1`, + `net/core/rtnetlink.c`), with **`ERANGE`**, not `EINVAL`: `validate_nla` + starts from `err = -ERANGE` (`lib/nlattr.c`), and the extack reads "Attribute + failed policy validation". This is a behavior, not a bug. +- `ip -6 route add … dev d0 table 4294967295` sends **two** requests: an + `RTM_GETLINK` to resolve `d0`, then the `RTM_NEWROUTE`. +- That request carries `rtm_table = RT_TABLE_UNSPEC` (0) plus `RTA_TABLE` + (`iproute_modify`, `ip/iproute.c`). The dump answers with + `RT_TABLE_COMPAT` (252) plus `RTA_TABLE` (`rt6_fill_node`, + `net/ipv6/route.c`). `TestWsSelftestTableMax` pins both. + +The two fresh-boot replays of the selftest batch matched row for row: the +same status, rc and packet count. + +#### tc from tdc (W1) + +tdc (`tools/testing/selftests/tc-testing`) is the kernel's own tc suite: +1334 cases, each a setup list, one command under test with its expected exit +status, and a verify command whose output must match a pattern a given +number of times. `nlcapgen tdc` (`tools/nlcapgen`) converts them into +`nix/microvms/workbench/tdc.rows.jsonl`: + +```sh +go run ./tools/nlcapgen tdc -src ~/Downloads/linux/tools/testing/selftests/tc-testing/tc-tests \ + -out nix/microvms/workbench/tdc.rows.jsonl -skipped nix/microvms/workbench/tdc.skipped.jsonl \ + -rev "$(git -C ~/Downloads/linux rev-parse --short=12 HEAD)" +nix run .#nlcap-ws -- batch nix/microvms/workbench/tdc.rows.jsonl -j 8 +``` + +- **The mapping.** `$TC`, `$IP` and tdc's device names keep their names + (`DEV0`/`DEV1` → the veth pair `v0p0`/`v0p1`, `DUMMY` → `dummy1`, `IFB` → + `ifbtdc0`), so the many patterns that name a device still match. The setup + becomes `pre`; the verify command becomes the `verify` readback, with + `matchPattern`/`matchCount` as `expect.match`; the teardown is dropped, + since the namespace is. A row loads its file's module (`sch_`, `cls_` or + `act_` plus the file name). +- **Devices only for nsPlugin cases.** tdc makes the veth, dummy and ifb only + for a case that requires `nsPlugin` (1303 of 1334). The other 31 make their + own devices (`teql.json` adds `$DUMMY` itself), and tdc runs them alone, so + their rows get no devices and are `serial`. A `$ETH` case uses netdevsim, + whose device ids are global: it runs `serial`, after removing the device an + earlier row left behind. +- **Patterns keep tdc's flags.** tdc compiles every pattern with + `re.DOTALL | re.MULTILINE` (`tdc.py`, verify), so the converter writes + `(?sm)` in front of each. Without it, 150 rows failed: their `.*` must + cross lines. A pattern RE2 cannot compile, a `matchJSON` check and a + pattern that counts scapy traffic are left unchecked, each with its reason + in `source.unchecked` (63 `matchJSON`, 17 scapy). +- **24 cases have no row**, each with its reason in `tdc.skipped.jsonl`: 12 + run tdc's helper scripts (`./tdc_*`, `./scripts/*`), 4 need a compiled eBPF + object, 4 a second physical device, 2 use gact's `random`, which needs + `CONFIG_GACT_PROB` (the workbench kernel has it off, so one case failed and + the other passed for the wrong reason). One's command under test is + `/bin/true` (it tests its setup, which the capture does not see). One, + `filters/u32/1234`, sends 4096 requests in one `tc -b` burst, and nlmon + dropped 3337 of 4858 datagrams even with the row alone. + +**Oracles** (`pkg/xtcpnl/xtcpnl_ws_tc_test.go`), on top of +`TestWsCorpusRows`: + +| # | test | holds | +|---|---|---| +| O6 | `TestWsTcCorpus` | every tc message in every capture parses (`ParseTcMsg`, `ParseTcAction`, `xtcpnl_tcmsg.go`) and carries no attribute past `TCA_MAX`, `TCA_ROOT_MAX` or `TCA_ACT_MAX` | +| O3 | `TestWsTcCorpus` | a `tc qdisc`/`class`/`filter` add, change, replace or delete sends what its command line says: kind, handle, parent, prio, protocol, chain and shared blocks. `tcWantFromCmd` reads the line as `tc_qdisc_modify`, `tc_class_modify` and `tc_filter_modify` do, with `get_qdisc_handle`, `get_tc_classid` and `ll_proto_a2n` | +| O4 | `TestWsTcCorpus` | a qdisc the kernel accepted is in the verify dump: same device and kind, and the same parent and handle where the request named them | +| O7 | `FuzzParseTcMsg`, `FuzzParseTcAction` | seeded with up to 400 distinct corpus messages each: no panic, and the byte fields a parse returns are copies | +| — | `TestWsSizeBudget` | each kernel's `ws/` stays under 64 MB of file bytes (20 MB until W4b) | + +O3 reads 546 requests and O4 checks 254 round trips. Commands outside the +model (pipelines, quoted arguments, `tc -b`, actions, chains) are counted +out rather than guessed at. Each oracle and the model have hand-built rows +that must fail; 17 of 18 mutants of them were killed, and the survivor was +equivalent (it removed a type mask the attribute walker already applies). + +**What the tdc rows showed:** + +- **Action dumps are numbered from 0.** `ParseTcAction` first refused nests + outside 1–32, which is how tc numbers a request. The kernel's dump + numbers from 0, and past 32 when the dump sets + `TCA_ACT_FLAG_LARGE_DUMP_ON` (`tcf_dump_walker`, + `net/sched/act_api.c`). A flush's reply is one nest 0 holding + `TCA_ACT_KIND` and `TCA_FCNT`, whose value 7 reads as `TCA_ACT_FLAGS` + (`tcf_del_walker`). The parser now refuses no order: 233 dumps failed O6 + until it did. +- **tc looks the device up before refusing an argument.** For a bad ematch + or an out-of-range index, tc exits 1 or 255 after one acknowledged + `RTM_GETLINK` and no tc request: "ack-then-fail" by the W0 rule. A row + that expects that rc has said it knew, so `wsRowProblem` accepts it then + (61 tdc rows), and still refuses it with any other rc. +- **`actions/batch-delete/8135` predates its fix.** It tests f67b3ed1308d + ("net/sched: act_api: reject duplicate actions in a batch", 7.4 merge + window). On 7.3.0 the delete still fails (rc 255), but the first action is + already gone, so tdc's pattern (the action survives) cannot match. The row + stays, so its capture records 7.3's behavior; its pattern is unchecked, + with that reason. +- **Determinism.** Three batches, the last two on fresh boots, gave every + row the same status, rc, message types and request count (dumps of shared + state are compared as type sets). A batch of 1310 rows takes ~12 minutes at + `-j 8`. The corpus is 4.1 MB of file bytes in ~9,200 files (34 MB of disk + blocks, which is not what git stores). + +#### Grammar matrix (W2) + +W2 is four commits, each verified on its own: + +- **W2a:** the generator and `ip route` for IPv4 and IPv6, with O3 and O4. + This section covers it first. +- **W2b:** `ip rule` and `ip nexthop`, in [its own part below](#w2b-ip-rule-and-ip-nexthop). +- **W2c:** O5, the goip differential over W2a's and W2b's rows, in + [its own part below](#w2c-o5-the-goip-differential). +- **W2d:** rows mined from the fib selftests, with O5 applied to them. + +`nlcapgen matrix` (`tools/nlcapgen/matrix.go`) builds rows from a table of +axes for each command. Each axis lists values, marked boundary or corner +where they are. `matrix_route.go` has two spaces: + +- **route4**, 10 axes: verb, destination, type, table, metric, protocol, + scope, MTU/advmss, source and nexthop. +- **route6**, 11 axes: the same, without scope, plus pref and expires. + +```sh +go run ./tools/nlcapgen matrix -family route -seed 1 -random 40 \ + -out nix/microvms/workbench/route.rows.jsonl +nix run .#nlcap-ws -- batch nix/microvms/workbench/route.rows.jsonl -j 8 +``` + +- **Strategies.** The generator writes these row groups: + - each-choice (`ec-`): every value appears at least once; + - pairwise (`pw-`): every pair of values from two axes appears in some + row, built greedily (AETG style); + - boundary (`bd-`): row n takes the n-th boundary value of every axis; + - seeded random (`rn-`): a SplitMix64 generator, so a seed always + gives the same rows, and a new seed gives new ones. + + A repeated assignment is dropped, and the same seed gives the same file + byte for byte. +- **Negatives.** These come from four sources: + - an *exclusion* (a pair of values the kernel or iproute2 refuses) keeps + the pair out of every positive row and writes one row expecting the + refusal; + - a *bad value* changes one axis of a positive row; + - `Judge` predicts refusals that depend on three axes or more, such as + IPv4 scope, which turns on type, scope and nexthop together. A judged + row stays in the matrix as a negative; + - `deriveRoute` adds repeated adds (EEXIST) and deletes of routes never + added (ESRCH). + + Each negative records the errno it expects, and `TestWsCorpusRows` + checks it. +- **Rows.** 343 rows: 228 the kernel accepts (13 positive, 78 boundary, + 137 corner) and 115 negatives: + + | outcome | rows | + |---|---| + | EINVAL | 55 | + | ESRCH | 19 | + | EEXIST | 10 | + | ENODEV | 10 | + | ENETUNREACH | 7 | + | refused by iproute2 before anything is sent | 10 | + | iproute2 exits after one acknowledged `RTM_GETLINK` | 3 | + | an unknown device (rc 1) | 1 | + +**Oracles** (`pkg/xtcpnl/xtcpnl_ws_route_test.go`), on top of +`TestWsCorpusRows`: + +| # | test | holds | +|---|---|---| +| O3 | `TestWsRouteCorpus` | an accepted row sends one request, built from its axes the way `iproute_modify` builds it. The test checks the verb's flags, the `rtmsg` (protocol, type, scope and table, plus the table rule: under 256 in the header, otherwise `RT_TABLE_UNSPEC` there and `RTA_TABLE` u32), the exact set of attributes and their widths, the values, `rtnh_hops` = weight − 1, and `RTA_METRICS` | +| O4 | `TestWsRouteCorpus` | after an accepted write, the `ip route show table all` dump has the route: same family, prefix, table, type and metric (IPv6 stores metric 0 as 1024). After an accepted delete, the route is gone | + +O3 checks 329 requests and O4 checks 228 round trips. The model and the oracles have +unit tables (`TestRouteWantFromAxes`, `TestRouteRequestProblem`, +`TestRouteDumpHas`, `TestRouteJudge`, `TestBuildRouteOrder`, +`TestMatrixJudge`). 17 of 17 mutants of the oracles, the model and the +generator were killed, each by a failing test. + +**What the route rows showed** (kernel 7.3.0, iproute2 7.2.0): + +- **An IPv4 reject route accepts `nhid`, and its scope is never checked.** + `fib_create_info` refuses a gateway, device or multipath on a + blackhole, unreachable, prohibit or throw route. It does not refuse a + nexthop id, and it then jumps to `link_it`, past every nexthop and scope + check (`net/ipv4/fib_semantics.c:1478-1484`). So + `ip route add blackhole 0.0.0.0/0 scope host nhid 10` succeeds. +- **An IPv6 multipath reject route fails with EEXIST.** + `fib6_nh_init` puts a reject route's nexthop on lo before it stores a + gateway (`net/ipv6/route.c:3623-3640`), so every nexthop is identical. + The second is then a duplicate of the first (`rt6_duplicate_nexthop`, + `net/ipv6/ip6_fib.c:1136-1142`). The same happens for add, append, + prepend and replace, and no extended-ack text explains it. +- **An IPv6 reject route can't be deleted with the arguments it was added + with.** The add accepts `dev d0` or `via …` and drops them, for the same + reason. A delete naming them then matches nothing in `ip6_route_del` + and fails with ESRCH (6 rows). Deleting by prefix, or by nhid, works. +- **A gateway at IPv4 link scope fails with ENETUNREACH, not EINVAL.** + `fib_check_nh_v4_gw` looks the gateway up at scope host, where only + local routes live, and returns the lookup's own error with the + extended-ack text "Nexthop has invalid gateway" + (`fib_semantics.c:1211-1242`). Onlink at that scope is EINVAL + ("Nexthop has invalid scope", `:1183-1186`). Broadcast and multicast + routes get link scope from iproute2 when no scope is given, so they + hit this too. An IPv6 gateway (RFC 5549) and an nhid without onlink + pass. At host scope, any gateway or second nexthop is EINVAL + (`:1510-1519`, `net/ipv4/nexthop.c:1626-1641`). +- **Every non-reject IPv6 type needs a device** (ENODEV), including + local and anycast. +- **`default` sends an all-zero `RTA_DST`.** With `-4` or `-6`, + `get_addr_1` sizes `default` to the family (`lib/utils.c:579-586`), so + it sends the same request as `0.0.0.0/0` or `::/0`. The O3 model first + assumed no `RTA_DST`, so O3 failed every accepted `default` row (28) + until the model was corrected. +- **`to` goes before the type.** `iproute_modify` takes `to` only ahead + of the type (`ip/iproute.c:1558-1566`), as `ip-route(8)` writes it + (`to TYPE PREFIX`). `ip route add unreachable to …` exits 1. The first + batch had 14 rows written that way, which was a generator bug. A + negative row keeps the wrong order. +- **A bad nexthop weight is refused after the device lookup.** + `parse_one_nh` resolves `dev` (one acknowledged `RTM_GETLINK`) before it + reads `weight`, so weight 0 or 257 is "ack-then-fail" with rc 255, as + tc's bad arguments are in W1. +- **Determinism.** The same 343 rows ran three times, the second on a + fresh boot, and wscmp found every row with the same status, rc and + message types each time. A batch takes ~90 s at `-j 8`, and a cold + `nlcap-ws up` takes 21 s. The corpus is 1.0 MB of file bytes in 2401 + files. + +##### W2b: `ip rule` and `ip nexthop` + +The same generator, with two more families: + +```sh +go run ./tools/nlcapgen matrix -family rule -seed 1 -random 40 \ + -out nix/microvms/workbench/rule.rows.jsonl +go run ./tools/nlcapgen matrix -family nexthop -seed 1 -random 40 \ + -out nix/microvms/workbench/nexthop.rows.jsonl +``` + +- **`matrix_rule.go`:** rule4 and rule6, 17 axes each: verb, `not`, + from, to, pref, fwmark, iif, oif, the DS field (tos, dsfield or dscp + with its mask), ipproto, sport, dport, uidrange, suppress, protocol, + an extra (tun_id, realms or, for IPv6, flowlabel) and the action + (lookup, goto, nop, the reject types, l3mdev). +- **`matrix_nexthop.go`:** nh4 and nh6 (verb, id, kind, protocol), and + nhgrp (verb, fam, id, members, type, fdb, hw_stats, protocol). A group + row first adds its members, ids 21 to 32, so the id axis (1 and 100) + never takes one of theirs. +- **Judges.** `ruleJudge` refuses a bare `ip rule add` or `del` (iproute2, + rc 1) and a goto at or below its own preference ("Backward goto not + supported"). `nhJudge` refuses a replace or delete without an id. +- **Repeats and deletes.** `deriveDupGone` (shared with route) adds a + repeated add (EEXIST) and a delete of something never added (ENOENT). + It only repeats a row that names a preference or an id: without one, + the kernel picks a new preference or id, so the second add makes a + second object instead of failing. +- **Rows.** + + | family | rows | accepted | negative | + |---|---|---|---| + | rule (rule4 203, rule6 203) | 406 | 351 (14 positive, 78 boundary, 259 corner) | 55 | + | nexthop (nh4 80, nh6 80, nhgrp 120) | 280 | 178 (34 positive, 97 boundary, 47 corner) | 102 | + + | outcome | rule | nexthop | + |---|---|---| + | EINVAL | 21 | 71 | + | ENOENT | 6 | 9 | + | EEXIST | 6 | 9 | + | ERANGE | 2 | | + | ENODEV (rc 255, from `RTM_GETLINK`) | | 2 | + | refused by iproute2 before anything is sent (rc 255 or 1) | 20 | 7 | + | iproute2 exits after one acknowledged `RTM_GETLINK` | | 4 | + +**Oracles** (`pkg/xtcpnl/xtcpnl_ws_rule_test.go`, +`xtcpnl_ws_nexthop_test.go`): + +| # | test | holds | +|---|---|---| +| O3 | `TestWsRuleCorpus` | the verb's flags; the `fib_rule_hdr` (family, prefix lengths, tos, action and the table rule: under 256 in the header, otherwise `FRA_TABLE` u32); the exact set of `FRA_*` attributes, each width (an interface name with its NUL, a zero-width `FRA_SRC` for `from all`) and value | +| O4 | `TestWsRuleCorpus` | the `ip rule show` dump has the rule, with the same selectors, preference, action, table and ranges; after a delete it is gone | +| O3 | `TestWsNexthopCorpus` | the verb's flags; the `nhmsg` (family, protocol, onlink); the attribute set and widths; `NHA_GROUP` as weight − 1 split over `weight` and `weight_high`; `NHA_GROUP_TYPE`; the `NHA_RES_GROUP` nest and what it holds | +| O4 | `TestWsNexthopCorpus` | the dump has the nexthop by id, with its family, protocol, gateway, device, blackhole, fdb, members and weights (only when the dump fills in `weight_high` or no weight passes 256) and the resilient values named; after a delete the id is gone | + +O3 checks 386 rule and 267 nexthop requests; O4 checks 351 and 178 round +trips. Unit tables: `TestRuleWantFromAxes`, `TestRuleRequestProblem`, +`TestRuleDumpMatches`, `TestNhWantFromAxes`, `TestNhWantForCmd`, +`TestNhRequestProblem`, `TestNhDumpMatches`, and in the generator +`TestRuleJudge`, `TestBuildRule`, `TestNhJudge`, `TestBuildNexthop` and +`TestMatrixFibRows`, `TestFibPredicates` and `TestNhMembers`. 26 of 26 +mutants of the oracles, the judges, the builders and the generator were +killed, each by a failing test. The last two tables were added for three +that first survived: a repeat predicate that always holds, a negative base +of any kind, and members back at id 1. + +**What the rule and nexthop rows showed** (kernel 7.3.0, iproute2 7.2.0): + +- **iproute2 7.2.0 can't send a nexthop `dst_port`.** `NHA_DST_PORT` + (18) is parsed only from iproute2 commit ad2b2d95 on, after 7.2.0. So + `dev d0 dst_port 4789` is ack-then-fail: one `RTM_GETLINK` for `d0`, + then rc 255. It is a negative row until the flake moves past it. +- **`type resilient` sends an empty `NHA_RES_GROUP` when anything + follows it.** `parse_nh_group_type_res` opens the nest whenever there + is a next argument, even one that isn't a resilient option (`type + resilient fdb`). When `type resilient` ends the line, no nest is sent. + O3 models both. +- **A new resilient group needs `buckets`.** Without them the kernel + refuses it with EINVAL ("Number of buckets not specified for nexthop + group insertion", `net/ipv4/nexthop.c:2692`). +- **A nexthop on an unknown device fails in the kernel, not in ip.** + `ll_name_to_index` asks with an `RTM_GETLINK`, the kernel answers + ENODEV, and ip exits 255 ("Device does not exist") without sending the + `RTM_NEWNEXTHOP`. +- **iproute2 takes weight 65536, the kernel doesn't.** `nhgrp_set_weight` + stores 65535 across `weight` and `weight_high`; the kernel refuses it + with EINVAL. 65535 is the largest it accepts. +- **A DSCP of 64 is ERANGE.** iproute2 sends `FRA_DSCP` 64 as given, and + `fib_rule_policy`'s `NLA_POLICY_MAX` refuses it ("Error parsing msg") + with ERANGE, not EINVAL. +- **A rule port range must end below 65535.** `sport 65535` passes + iproute2 and the kernel refuses it ("Invalid sport range"); 65534 is + accepted. An IPv4 `tos 0xb8` (past `IPTOS_TOS_MASK`) is EINVAL too. +- **`from all` sends a zero-length `FRA_SRC`.** The first O3 model sent + the family's width of zeros, which is what `from 0.0.0.0/0` sends. +- **A repeated rule without a preference isn't EEXIST.** + `fib_default_rule_pref` gives the second add a new preference, so it + succeeds as a second rule. +- **Determinism.** Both rows files ran twice, the second on a fresh + boot, and wscmp found every row with the same status, rc and message + types. The two families' corpus is 1.5 MB in 4802 files. + +##### W2c: O5, the goip differential + +`TestWsGoipDifferential` (`internal/goip/ws_differential_test.go`) runs +every route, rule and nexthop row through goip offline, against the row's +own `ip.pcap`: + +- goip's `RTM_GETLINK` lookups are answered from the capture, so `d0` + resolves to the ifindex the kernel gave ip; +- goip's write goes to `GOIP_WRITE_OUT` and is never sent; +- ip's requests are split into its leading `RTM_GETLINK` lookups and the + rest. The lookups and the write are compared separately, so a lookup + difference can't hide whether the write matches. + +Each row's write gets one outcome: + +| outcome | means | +|---|---| +| same | the same write byte for byte (seq and pid zeroed), or both refuse at a check goip implements | +| refuses | goip refuses a command ip sent, or goip and ip both refuse but goip's reason is "not implemented" | +| sends | goip writes a request ip refused to send | +| differs | both write, and the writes differ; the report names the nlmsg flags, the family header or the attribute types | + +Every row whose outcome isn't "same", and every row whose lookups differ +other than in the known way, is listed in +`internal/goip/testdata/ws-differential.txt`. A new divergence and a fixed +one both fail the test until the file is regenerated with `-update-ws-o5`. + +| space | same | refuses | sends | differs | lookups differ | +|---|---|---|---|---|---| +| route4 | 132 | 58 | 0 | 0 | 88 | +| route6 | 55 | 98 | 0 | 0 | 37 | +| rule4 | 173 | 30 | 0 | 0 | 0 | +| rule6 | 203 | 0 | 0 | 0 | 0 | +| nh4 | 78 | 2 | 0 | 0 | 38 | +| nh6 | 78 | 2 | 0 | 0 | 36 | +| nhgrp | 81 | 39 | 0 | 0 | 0 | + +**What O5 showed:** + +- **goip's writes match ip's on every row goip implements.** No row + differs. +- **goip took a `protocol` past 255, and ip doesn't.** `rtnl_rtprot_a2n` + refuses a number over 255 (`lib/rt_names.c:293`, in every iproute2 + release since well before 7.1). goip's rule, nexthop and neigh parsers + allowed a u32 and sent its low byte, so `protocol 256` was a write where + ip sends nothing (4 "sends" rows). They now refuse it. The nexthop test + that expected `protocol 0x1ff` to send 0xff now expects 255 to be the + largest it takes, and 256 to be refused. +- **All 199 lookup differences are `IFLA_EXT_MASK`.** iproute2 7.2.0's + `ll_link_get` adds `RTEXT_FILTER_NAME_ONLY` (1<<8, `lib/ll_map.c:276`), + so ip asks with 0x109 where goip, which models 7.1, asks with 0x9. goip + stays on 7.1 until its parity target moves; the report counts these rows + in its header and doesn't list them one by one. +- **What goip refuses:** + - route: 156 rows on keywords goip hasn't implemented (`pref` 68, `nhid` + 49, `nexthop` 29, `expires` 10); + - nexthop: 43 rows on `hw_stats` (41) and `dst_port` (2), which no + capture showed before; + - rule: 30 rows on `realms`. iproute2 reads realm names from + `rt_realms`, and goip reads no config files. +- **Both refusing isn't always agreement.** goip doesn't implement + multipath `nexthop`. On `nexthop via … dev d0 weight 257`, ip resolves + `d0` and then refuses the weight, while goip stops at `nexthop`. O5 first + counted these 3 rows as "same". A goip "not implemented" now makes the + row "refuses", whatever ip did. +- **Tests:** `TestWsO5Compare`, `TestWsO5Lookups`, `TestWsO5Split`, + `TestWsO5Argv` and `TestWsO5GoldenDiff` are table tests on hand-built + inputs. They cover the + branches the corpus doesn't reach yet: every "differs" detail, a lookup + that differs in more than its mask, and a lookup after the write. +- **Mutation:** 20 of 20 mutants killed, each by a relevant test (outcome + classification, the lookup split and comparison, the diff detail, the + golden comparison, `-6` parsing, and the three protocol limits). Two + needed a second try: + - "a rule's header length is wrong" first used `RtMsgSizeCst`, which is + equivalent, since `rtmsg` and `fib_rule_hdr` are both 12 bytes; + - "the golden file isn't compared" first survived, because a correct + report matches it. The comparison is now `wsO5GoldenDiff`, with its own + table test. That test found that a reordered or repeated line passed, + and the helper now reports it. + +##### W2d: rows mined from the fib selftests + +`nlcapgen kselftest` (`tools/nlcapgen/kselftest.go`) reads +`fib_tests.sh`, `fib_nexthops.sh` and `fib_rule_tests.sh` from +`tools/testing/selftests/net` in `~/Downloads/linux` (6b4b543fa187). It +writes `nix/microvms/workbench/fib.rows.jsonl` and lists every line it +skips, with the reason, in `fib.skipped.jsonl`. + +The miner is a static walker, not a shell: + +- **Groups:** each test the script's main loop runs (`$TESTS` or + `$ALL_TESTS`, through its `case` arms) is one group, with the script's + per-test prelude (`setup`) walked first. +- **What it follows:** top-level and `local` assignments, `$1`–`${9}` in + inlined functions, `for` over a literal list or `$(seq [A] B)`, an + `if`'s then branch, `set -e`/`set +e`, `run_cmd "…"` and + `log_test $? N "msg"`. +- **What it skips, and says so:** command substitutions, compound + commands, unresolved variables, other namespaces, `ip netns` (the + workbench gives every row its own), `else` branches, a then branch that + returns (a failure check), and a then branch whose condition reads an + earlier rc (`if [ $rc -eq 0 ]`). +- **Rows:** each route, rule or nexthop command becomes a row. Its pre + steps are lib.sh's `rp_filter` sysctls, then every step of its group + before it. A step the selftest doesn't require to succeed is a + tolerated pre step. +- **rc:** `log_test $? N` after the command gives the row rc N + (positive: for 0, negative: otherwise); `set -e` gives rc 0. Anything + else is a corner: row with no rc, which the corpus test still holds to + "an iproute2 or kernel refusal, never ack-then-fail". +- **Namespaces:** `-netns` naming the test's own namespace is dropped. + `ip link … netns X` keeps the device here, and a later + `ip -netns X link set D up|down` for that device is kept too, since + some tests check what a peer's link state does to a nexthop. + +| space | rows | positive | negative | corner | +|---|---|---|---|---| +| fibt | 373 | 129 | 32 | 212 | +| fibnh | 452 | 71 | 62 | 319 | +| fibrule | 160 | 4 | 0 | 156 | + +Of the corner rows, 148 are commands whose output the selftest reads +(`ip route get … | grep`), 188 follow a write the miner skipped, and 10 +use `dst_port`. 296 lines are skipped: 87 in other namespaces, 81 under a +test of an earlier rc, 47 `ip netns`, 24 compound commands, 22 unresolved +variables, and 35 others. + +**What recording showed (two fresh runs, 985 of 985 OK, the same message +types and rcs in both):** + +- **The first run had 367 rows fail,** every one a miner bug. Each now has + a test row: + - `for i in 10 20 … 70;` with `do` on the next line kept the `;` + (`dummy_70;`); + - `$(seq 6)` (one argument) wasn't expanded, so `dummy4` never existed; + - `${1}` didn't expand, so `create_ns $me`'s sysctls were skipped as + another namespace. Among them is `keep_addr_on_down=1`, without which + `ip link set veth1 down` drops `2001:db8:91::1` for good, and the next + `nexthop … via 2001:db8:91::2` fails with "No route to host"; + - `$NS_EXEC sysctl …` wasn't recognised as ip at all; + - `cmd 2>/dev/null || true` under `set -e` was taken as required; + - `if [ $rc -eq 0 ]; then ip rule del …` was walked after an add the + selftest expects to fail (276 rows). +- **A step after a skipped write loses its rc.** `fib_rule_tests.sh` + deletes its rules by `pref $pref`, where `$pref` comes from + `$(ip rule show … | cut)`. The miner can't know it, so the rule stays, + and what the selftest states about later steps no longer holds. Those + 188 rows are corner: rows, marked "follows line N, which the miner + skipped". +- **The selftests are newer than the workbench's iproute2.** iproute2 + 7.2.0 predates `nexthop … dst_port` (ad2b2d95, after 7.2.0), so ip + refuses it before sending (rc 255) where the selftest expects the + kernel's rc 2. `ksNewer` lists such keywords with the commit; their rows + state no rc, and their pre steps are tolerated. + +**O5 over the mined rows.** O5 compares only write verbs (add, change, +replace, prepend, append, del, delete), and resolves abbreviated objects +(`ro`, `nex`) as goip does. Get, show, flush and bucket rows are counted +as "not a write". + +| space | same | refuses | sends | differs | lookups differ | not a write | +|---|---|---|---|---|---|---| +| fibt | 127 | 103 | 0 | 0 | 44 | 143 | +| fibnh | 316 | 86 | 0 | 0 | 151 | 50 | +| fibrule | 72 | 0 | 0 | 0 | 4 | 88 | + +- **goip's writes match ip's on every mined row goip implements.** Every + lookup difference is the known `IFLA_EXT_MASK`. +- **ip sends a byte of stack after a `congctl` name.** `ip route add … + congctl lock foo` first showed as 2 "differs" rows, differing in one + byte of `RTA_METRICS`: the pad after `RTAX_CC_ALGO "foo"` was 0xc4 and + 0xe9 from ip, and 0 from goip. iproute2 builds the metrics in an + uninitialized `char mxbuf[256]` (`ip/iproute.c:1172`), and + `rta_addattr_l` doesn't clear the alignment padding + (`lib/libnetlink.c:1500-1505`). The kernel ignores padding, so nothing + breaks, but the byte changes from run to run. O5 now zeroes the padding + of `RTA_METRICS` sub-attributes before comparing, and counts the rows + where it wasn't zero (4). +- **What goip refuses in the mined rows:** route `nhid` (73), multipath + `nexthop` (57), `expires` (25), `vrf` (15) and `dsfield` (5); nexthop + `dst_port` (10) and `encap` (4). +- **Tests:** `TestMineScript` (43 rows), `TestMineScriptRowFields`, + `TestMineScriptPrelude`, `TestKsGroups`, `TestShLines`, `TestShWords`, + `TestIPObject` and `TestMineKselftests` cover the miner on small + scripts. `TestWsO5Applies`, the new `TestWsO5Argv` rows and + `TestWsO5ZeroMetricsPad` cover O5's side. `TestParseNexthopBucketCorpus` + now decodes 9 `RTM_GETNEXTHOPBUCKET` requests, up from 2: the mined rows + add three gets and four dumps. +- **The miner leaves out iproute2's British-spelled alias for `neighbor`,** + which lint and the kernel-citation audit reject outside kernel paths. No + prefix of `neighbor` reaches it, and no mined selftest types it, so the + rows don't change. +- **Mutation:** 29 of 29 miner mutants and 5 of 5 O5 mutants killed, each + by a relevant test. The first pass left four survivors, each fixed by + adding a test or removing dead code: + - a pipe reader that stays required, and a module taken from any word + rather than the one after `type`, each needed a test row; + - skip deduplication across groups needed `TestMineScriptPrelude`; + - `logTest`'s check for a step after a skipped write was dead code (such + a step never becomes the one `log_test` reads), so it was removed. +- **Size:** the rows file is 2.2 MB, because each row repeats its group's + steps as pre steps (up to 124). The recorded rows add 6.6 MB of file + bytes to `ws/`, which then holds 13.4 MB, under the 20 MB budget. A run + of 985 rows takes about nine minutes at `-j 8`. + +#### Grammar matrix for addresses, neighbors and links (W3) + +W3 applies W2's method to `ip address`, `ip neigh` and `ip link`, one +commit each: W3a addresses, W3b neighbors, W3c links. + +##### W3a: `ip address` + +`nlcapgen matrix -family addr -seed 1 -random 40` writes +`nix/microvms/workbench/addr.rows.jsonl` from two spaces in +`tools/nlcapgen/matrix_addr.go`. Each row runs in a fresh namespace +holding one dummy device, `d0`, up with no address, so the address under +test is the only one on it. A `change` or `del` first adds the address it +works on. + +| space | axes | rows | positive | boundary | corner | negative | +|---|---|---|---|---|---|---| +| addr4 | verb, local, peer, brd, label, scope, metric, lft, flags, proto | 132 | 1 | 12 | 87 | 32 | +| addr6 | the same; brd, label and scope have only the values IPv6 ignores | 128 | 1 | 10 | 56 | 61 | + +Nearly every row takes at least one unusual spelling (`priority` for +`metric`, `bro` for `broadcast`, `local` before the address), which makes +it `corner:`. + +**What the kernel and iproute2 do** (each is a row, or a judged +negative): + +- **ip exits 1, not 255, when `ipaddr_modify` returns -1.** `do_cmd` + negates the return value (`ip/ip.c:131`), so the checks after the device + lookup (`autojoin needs multicast address`, `valid_lft is zero`, + `preferred_lft is greater than valid_lft`) exit 1, as does IPv6's + `brd +`. Only `invarg()` exits 255. +- **IPv4 `autojoin` with a peer is EINVAL.** `ip_mc_autojoin_config` joins + `ifa_address`, which a peer replaces, and `__ip_mc_join_group` refuses a + unicast group (`net/ipv4/igmp.c:2363`). A plain exclusion keeps the + pair out of positive rows; `Derive` writes the negative, since every + negative base row is unicast. +- **Adding `0.0.0.0` succeeds and stores nothing.** `inet_rtm_newaddr` + returns 0 for a zero `IFA_LOCAL` (`net/ipv4/devinet.c:961`). O4 checks + that the dump doesn't hold it, and a delete of it is EADDRNOTAVAIL. +- **IPv6 refuses a multicast address without `autojoin`, and the + unspecified one** (`ipv6_add_addr`, EADDRNOTAVAIL). `mngtmpaddr` needs a + prefix length of 64, and it is the peer's length that counts when there + is one (EINVAL, checked first). +- **`nodad optimistic` is not refused while optimistic DAD is off.** The + kernel clears `IFA_F_OPTIMISTIC` before it checks the pair. +- **IPv6 ignores `label`, `scope` and `broadcast`.** iproute2 sends them; + O4 compares them for IPv4 only. IPv6-only flags on an IPv4 address draw + a warning and aren't sent. +- **An IPv4 delete without a prefix length is a wildcard deletion.** + ipaddr_modify leaves `IFA_ADDRESS` out, and the kernel matches on the + local address alone. + +The first run's 41 failures were all predictions of the first two kinds +(rc 255 where ip exits 1, and `autojoin` with a peer). There were no +command or capture bugs. + +**Oracles** (`pkg/xtcpnl/xtcpnl_ws_addr_test.go`): + +- **O3** rebuilds the `AddrInfo` the request should parse to from the + row's axes, and checks the attribute set, each width and the header's + `ifa_flags`. The model covers: `brd +`/`-` computed from the peer or the + local address (none past /30, none on a delete), the wildcard deletion, + `default_scope`'s host scope for 127/8, and flags past 0xff moving to + `IFA_FLAGS` with the header 0. The index must be the one the kernel gave + `d0` in its answer to ip's lookup. O3 checked 213 requests. +- **O4** looks for the address in `ip address show dev d0`'s dump after + each accepted write (167 rows): its addresses, prefix length, metric, + protocol and flags, plus the label, scope and broadcast address for + IPv4. After a delete, or a zero address, the dump must not hold it. +- **O5:** goip sends the same request as ip on all 260 rows (addr4 132, + addr6 128). The only lookup difference is the known `IFLA_EXT_MASK`. +- **Determinism:** a second run of all 260 rows, on the same workbench + boot, gave the same status, rc and message types on every row. + +**Tests:** `TestAddrJudge` (16 rows), `TestBuildAddr`, `TestSpaceAssign`, +`TestAddrCanDup`, `TestAddr4Exclusions` and `TestMatrixAddrRows` cover the +generator. `TestAddrComputedBroadcast`, `TestAddrWantFromAxes`, +`TestAddrRequestProblem` and `TestAddrDumpMatches` cover the oracles, and +`TestWsO5HdrLen` covers O5's header lengths. + +**Mutation:** 27 of 27 mutants killed, each by a relevant test. The first +pass left four survivors, each fixed by adding a test: + +- `addrCanDup` and the `autojoin`-with-a-peer negative weren't reached by + any row check, so each got a direct test; +- an exclusion swapped for another wasn't visible in the rows, since no + positive row happened to take the pair. `TestAddr4Exclusions` now checks + the compiled space; +- O5's header length for addresses was used only to describe a + difference, and no row differs. `TestWsO5HdrLen` checks it directly. + +**Size:** the rows add 771 KB of file bytes to `ws/`, 14.2 MB in all. + +##### W3b: `ip neigh` + +`nlcapgen matrix -family neigh -seed 1 -random 40` writes +`nix/microvms/workbench/neigh.rows.jsonl` from two spaces in +`tools/nlcapgen/matrix_neigh.go`. Each row runs in a fresh namespace +holding a veth pair, `d0` and `d1`, both up, with 192.0.2.10/24 and +2001:db8:1::10/64 on `d0`. A dummy device won't do here, since it is NOARP +and resolves nothing. A `change` or `del` first adds the entry it works +on, and a proxy row reads back `ip neigh show proxy`, since the plain dump +leaves proxy entries out. + +| space | axes | rows | corner | negative | +|---|---|---|---|---| +| neigh4 | verb, dst (incl. broadcast, multicast, proxy), lladdr, nud, flags, protocol | 138 | 87 | 51 | +| neigh6 | the same, with a link-local destination in place of broadcast | 137 | 82 | 55 | + +No row is plain `positive:`. The all-plain row (`add 192.0.2.1 dev d0`) +has no link-layer address, so it is a judged negative, and every other row +takes at least one corner value. + +**What the kernel does** (`net/core/neighbour.c` `neigh_add` and +`__neigh_update`, each a row or a judged negative). All 275 rows passed +on the first run. + +- **A new entry in a valid state needs a link-layer address**, or the + kernel refuses it with EINVAL ("No link layer address given"). That + includes the default state, NUD_PERMANENT, so `ip neigh add 192.0.2.1 + dev d0` fails. +- **Not for a multicast or broadcast destination.** `arp_constructor` and + `ndisc_constructor` start such an entry NUD_NOARP, which is valid, so + the kernel keeps the address it already has. +- **`none`, `incomplete` and `failed` need no address.** They aren't + NUD_VALID, so `__neigh_update` stores the state and returns. +- **`use` and `managed` return before that check.** `managed` also sets + the state to NUD_NONE, and it wins over a `nud` before it on the line. +- **`extern_valid` needs a valid state** on a new entry (EINVAL, "Cannot + create externally validated neighbor with an invalid state"). +- **A proxy entry refuses `managed` and `extern_valid`** ("Invalid NTF_* + flag combination") and takes anything else. Added twice, it is found + rather than refused, so the duplicate-add negatives skip proxy rows. +- **`change` of an absent entry is ENOENT.** change sends NLM_F_REPLACE + without NLM_F_CREATE. `Derive` writes three such rows (`chgone-*`). +- **`router` is stored only with a valid state.** `__neigh_update` leaves + for an invalid state, `use` or `managed` before it updates + `NTF_ROUTER` (`net/core/neighbour.c:1460`), while `extern_learn` is set + earlier, by `neigh_update_flags`. O4 first expected the router flag on + 8 such rows. + +**A parser gap closed.** `ParseNeigh` skipped `NDA_PROTOCOL`, which ip sends +for `protocol` and the kernel dumps back. `NeighInfo` now has `Protocol` +and `HasProtocol`, with table rows and `NdaProtocol` pinned at 12 next to +`NdaFlagsExt`. + +**Oracles** (`pkg/xtcpnl/xtcpnl_ws_neigh_test.go`): + +- **O3** rebuilds the `NeighInfo` the request should parse to: the state + (NUD_PERMANENT unless named), `NTF_PROXY`, `NDA_FLAGS_EXT` only when an + extended flag is set, and `NDA_PROTOCOL` only when named. The index must + be `d0`'s, found by name in the link dump ip reads first (`ll_init_map`), + where `lo` comes first. O3 checked 262 requests. +- **O4** finds the entry in the dump after each accepted write (169 rows). + It checks the flags asked for, the extended flags and the protocol, plus + the state and link-layer address for a permanent or noarp entry, unless + `use` or `managed` handed the entry to the kernel to resolve. A + reachable entry may have gone stale by the readback, so other states + aren't compared. +- **O5:** goip sends the same request as ip on all 275 rows (neigh4 138, + neigh6 137). ip neigh dumps every link before its write, and O5 used to + count that dump as a lookup goip skipped. `wsO5Split` now sets ip's + leading `NLM_F_DUMP` link requests apart and compares them with goip's + dumps byte for byte. They differ only in `IFLA_EXT_MASK`: 0x109 from ip, + 0x1 from goip. iproute2 7.2.0's de91e928 added + `RTEXT_FILTER_SKIP_STATS | RTEXT_FILTER_NAME_ONLY` to `ll_init_map`'s + dump as well as to `ll_link_get`, and goip models 7.1. The report's mask + notes now say whether they come from a lookup or a dump. +- **Determinism:** a second run of all 275 rows, on the same workbench + boot, gave the same status, rc and message types on every row. + +**Tests:** `TestNeighJudge` (17 rows), `TestBuildNeigh`, `TestNeighCanDup`, +`TestDeriveNeighChangeGone` and `TestMatrixNeighRows` cover the generator. +`TestNeighWantFromAxes`, `TestNeighRequestProblem` and +`TestNeighDumpMatches` cover the oracles. `TestWsO5Split`, `TestWsO5Lookups` +and `TestWsO5MaskOnly` cover O5's dump handling. + +**Mutation:** 33 of 33 mutants killed, each by a relevant test. The first +pass ran 35 and left three survivors: + +- the derive test didn't check that a proxy change was skipped, since + its proxy and plain sources had the same command. It now checks the + derived row's axes; +- `managed` setting the state to NUD_NONE in the judge was dead code. + `flags` is one axis, so `managed` never meets `extern_valid`, and it was + removed; +- judging a change's setup add as an add was equivalent to judging the + change itself, since the judge refuses one exactly when it refuses the + other. The line was removed. Neither removal changed the rows. + +**Size:** the rows add 707 KB of file bytes to `ws/`, 14.9 MB in all. + +##### W3c: `ip link set` + +`nlcapgen matrix -family link -seed 1 -random 40` writes +`nix/microvms/workbench/link.rows.jsonl` from one space, `linkset`, in +`tools/nlcapgen/matrix_link.go`. Each row runs in a fresh namespace +holding one dummy device, `d0`, down, and runs `ip link set dev d0 …`. The +readback dumps every link (`ip -d link show`), so a rename is still found. +`ip link add`, kind by kind, is W3d. + +| axis | values (boundary values in bold) | +|---|---| +| updown | absent, up, down | +| mtu | absent, 1400, **0**, **65535** | +| txqlen | absent, 500, **0**, **32767** (`qlen`), `txq 100` | +| group | absent, 5, `default`, **2147483647** | +| alias | absent, `hello`, **255 characters** | +| address | absent, a MAC, `addr` abbreviated, `broadcast` | +| flag | absent, multicast on and off, promisc, allmulticast, arp off, dynamic, trailers off | +| gso | absent, gso_max_size, gso_max_segs (**65535**), gro_max_size (**524280**), gso_ipv4_max_size, gro_ipv4_max_size | +| mode | absent, `mode dormant`, `mode default`, `state dormant` | +| name | absent, `name d9` | + +130 rows: 8 positive, 7 boundary, 105 corner and 10 negative. All 130 +passed on their first run, so every prediction about a dummy device held: + +- **A dummy has no MTU limits.** It sets `min_mtu` and `max_mtu` to 0 + (`drivers/net/dummy.c:126`), so `mtu 0` and `mtu 65535` are taken. + `mtu -1` passes `get_integer` and the kernel refuses it as negative + (EINVAL, "mtu less than device minimum"). +- **`txqueuelen 32768` is ERANGE.** `ifla_policy` bounds `IFLA_TXQLEN` at + S16_MAX (`txqlen_range`), before `netif_change_tx_queue_len`'s own check. +- **`gro_max_size 524281` is EINVAL** (past `GRO_MAX_SIZE`, 8 × 65535, in + `validate_linkmsg`). `gso_max_segs 65536` never reaches the kernel: + iplink_parse refuses it against `GSO_MAX_SEGS`. +- **A multicast MAC is EADDRNOTAVAIL** (`eth_mac_addr`). A 2-byte address + is refused by iproute2 after it has looked d0's address length up + (`nl_get_ll_addr_len`), so it exits 1 with an acknowledged lookup. +- **An alias of 256 characters is refused by iproute2** (`alias too long`, + IFALIASZ), and a name of 16 by `check_ifname`. + +**A parser gap closed.** `ParseNewLink` skipped `IFLA_IFALIAS`, ip's +`alias` line. `LinkInfo` now has `IfAlias`. The kernel dumps it +NUL-terminated and iproute2 sends it without the NUL; both decode alike. + +**Oracles** (`pkg/xtcpnl/xtcpnl_ws_link_test.go`): + +- **O3** rebuilds every attribute's bytes from the axes and compares them, + in any order, with none missing, extra or repeated. It also checks the + `ifinfomsg`: AF_UNSPEC, d0's index, and `ifi_flags`/`ifi_change` from + up, down and the on/off flag (`arp off` and `trailers off` set + `IFF_NOARP` and `IFF_NOTRAILERS`). No `NLM_F_CREATE`. O3 checked 124 + requests. +- **O4** finds `d0` by index in the dump after each accepted write (120 + rows), and checks its name, the flags under the change mask, MTU, + txqueuelen, group, the GSO and GRO limits, link mode, address, + broadcast and alias. The operational state isn't compared: a down + device's stays down whatever `state` asked for. +- **O5:** `set` is now one of O5's write verbs, run through goip's `link` + object. goip sends the same request as ip on only 8 of the 130 rows. + The other 122 stop at a keyword goip recognizes but hasn't implemented, + counted by the first one each row reaches: `group` 61, `gso_max_segs` + 16, `gro_max_size` 15, `gro_ipv4_max_size` 7, `gso_ipv4_max_size` 6, + `broadcast` 6, `gso_max_size` 5, `mode` 3, `state` 1, and the + address-length lookup 2. That is goip's to-do list for `ip link set`. + Implementing it is outside W3. +- **Determinism:** a second run of all 130 rows, on the same workbench + boot, gave the same status, rc and message types on every row. + +**Tests:** `TestBuildLinkSet` and `TestMatrixLinkRows` cover the +generator. `TestLinkSetWantFromAxes`, `TestLinkSetRequestProblem` and +`TestLinkDumpProblem` cover the oracles, and three `TestParseNewLink` rows +cover `IfAlias`. + +**Mutation:** 25 of 25 mutants killed, each by a relevant test. One +survived the first pass: the longest-alias test compared the command with +the constant that built it, so shortening the constant changed both. It +now spells out 255 characters itself. + +**Size:** the rows add 737 KB of file bytes to `ws/`, 15.6 MB in all. They +are larger than W3a's and W3b's because every readback dumps both links +with `-d`, and 1 in 5 rows carries the 255-character alias. + +##### W3d: `ip link add` + +The second space in `link.rows.jsonl`, `linkadd` +(`tools/nlcapgen/matrix_linkadd.go`). The `linkset` rows come first and +keep their ids, so W3c's captures are unchanged. Each row runs in a fresh +namespace holding `d0`, a dummy that is up, as the lower device a vlan or +macvlan names, and adds `n0`. The readback shows `n0` alone. + +| axis | values (boundary values in bold) | +|---|---| +| name | `n0`, `dev n0`, `name n0` | +| up | absent, up | +| mtu | absent, 1400, **1500**, 9000 | +| txqlen | absent, 100, **0** | +| address | absent, a MAC | +| queues | absent, `numtxqueues 4`, `numrxqueues 4`, both | +| kind | dummy; ifb; veth (`peer name p0`, and with the peer's mtu); vlan (`id 10`, **`id 4094`**, `protocol 802.1ad`); macvlan (`mode bridge`, `private`, `passthru`); bridge (and `stp_state 1`) | + +111 rows: 10 positive, 8 boundary, 74 corner and 19 negative. Three were +wrong on the first run: + +- **A bare name of 16 characters is refused by the kernel.** A bare name is + iplink_parse's `dev`, which `check_altifname` allows up to ALTIFNAMSIZ, + so ip sends it and `ifla_policy` refuses `IFLA_IFNAME` (ERANGE). The + `name` keyword goes through `check_ifname`, which refuses it in ip + (W3c). +- **`vlan id 65536` is refused after a lookup.** `vlan_parse_opt` runs after + iplink_parse has looked `d0` up, so ip exits 255 with an acknowledged + `RTM_GETLINK`. +- **An unknown kind is ENOTSUP.** The kernel returns EOPNOTSUPP ("Unknown + device type"), which Linux numbers the same as ENOTSUP, and that is the + name `unix.ErrnoName` gives it. + +The rest held: + +- **A vlan's or macvlan's MTU may not pass its lower device's** (1500 on + `d0`, EINVAL). A veth, bridge, dummy or ifb takes 9000. +- **VLAN id 4095 is ERANGE**, and 4094 is the largest taken. +- **A vlan with no lower device is EINVAL.** +- **Adding `n0` twice is EEXIST** (three `dup-*` rows). + +**Oracles** (`pkg/xtcpnl/xtcpnl_ws_link_test.go`): + +- **O3** checks the top-level attributes by bytes, with `IFLA_LINK` as + `d0`'s index, plus the `IFLA_LINKINFO` nest byte for byte: + `IFLA_INFO_KIND` with its NUL, then the kind's `IFLA_INFO_DATA`. The + model covers: + - a veth peer: a zeroed `ifinfomsg`, with the peer's `IFLA_MTU` before + its `IFLA_IFNAME`, as the peer's iplink_parse adds them; + - a vlan protocol in network order before a host-order id; + - a macvlan mode as a u32; + - a bridge option. + + It also checks `NLM_F_CREATE|NLM_F_EXCL` and index 0. O3 checked 109 + requests. +- **O4** finds `n0` in the dump (92 rows) and checks its kind, up, lower + device, MTU, txqueuelen, queue counts and address. It also checks the + decoded kind data: a vlan's id and protocol (802.1Q when none is named), + a macvlan's mode and a bridge's STP state. +- **Two findings from O4:** + - `ip link show dev n0` first resolves the name, and iproute2 7.2.0 + asks with `RTEXT_FILTER_NAME_ONLY`, so the first reply about `n0` + holds its name alone. The full reply comes second, and O4 takes the + last. + - A passthru macvlan takes its lower device's address whatever was + asked (`eth_hw_addr_inherit`, `drivers/net/macvlan.c:1552`), so its + address isn't compared. +- **O5:** goip sends the same request as ip on 43 rows, across all six + kinds. The other 68 stop at `numtxqueues` (47) or `numrxqueues` (21), + which goip hasn't implemented. Every lookup difference is the known + `IFLA_EXT_MASK`. +- **Determinism:** a second run of all 111 rows, on the same workbench + boot, gave the same status, rc and message types on every row. + +**Tests:** `TestLinkAddJudge`, `TestBuildLinkAdd`, `TestDeriveLinkAddDup` +and `TestMatrixLinkAddRows` cover the generator. `TestLinkAddInfoData`, +`TestLinkAddRequestProblem` and `TestLinkAddDumpProblem` cover the +oracles. + +**Mutation:** 23 of 23 mutants killed, each by a relevant test. Two +mutants were rewritten before they counted, neither a survivor: + +- "queue counts unchecked in the dump" first panicked on rows with no + queue count. It now drops the check, and `TestLinkAddDumpProblem` gained + a tx-queue row to kill it; +- "a passthru address compared" first didn't build. + +**Size:** the rows add 321 KB of file bytes to `ws/`, 16.0 MB in all, +against the 20 MB budget. + +#### Netlink coverage report (W4) + +`nlcapgen coverage -linux DIR -ws DIR -out FILE` (`tools/nlcapgen/coverage.go`) +writes [`ws-coverage.txt`](ws-coverage.txt). For each message family the +rows write, it lists which attributes of the kernel's policy table some +request in the workbench corpus sent. That is every row's `ip.pcap`: 4106 +captures. The never-sent list is the to-do list for the next axis values. + +- **The policy** is the set of designators in the `nla_policy` table, read + from the Linux tree: `rtm_ipv4_policy`, `rtm_ipv6_policy`, + `fib_rule_policy`, `rtm_nh_policy_new`, `ifa_ipv4_policy`, + `ifa_ipv6_policy`, `nda_policy` and `ifla_policy`. The UNSPEC entry + holds only `strict_start_type`, so it isn't counted. +- **Attribute numbers** come from the uapi enum: its place in the enum, or + an explicit value. +- **The requests counted** are every type whose kernel handler parses with + that table. The route, address, link and neighbor tables also judge + their family's gets and dumps (`inet_rtm_getroute`, the address dump + checks, `rtnl_getlink`, the neighbor get and dump checks). The rule and + new-nexthop tables judge only writes. +- Route and address requests are split by family, so an `AF_UNSPEC` + address dump counts for neither. + +| family | sent / policy | requests | never sent, though ip has a keyword for it | never sent, and no iproute2 code sends it | +|---|---|---|---|---| +| route4 | 12 / 19 | 494 | `RTA_FLOW` (realms), `RTA_ENCAP`/`_TYPE` (encap), `RTA_UID`, `RTA_IP_PROTO`, `RTA_SPORT`, `RTA_DPORT` (route get) | — | +| route6 | 12 / 19 | 461 | `RTA_ENCAP`/`_TYPE`, `RTA_UID`, `RTA_IP_PROTO`, `RTA_SPORT`, `RTA_DPORT`, `RTA_FLOWLABEL` (route get) | — | +| rule | 23 / 23 | 464 | — | — | +| nexthop | 11 / 12 | 494 | `NHA_DST_PORT`, which iproute2 7.2.0 predates (W2d) | — | +| addr4, addr6 | 8 / 9, 6 / 7 | 110, 103 | — | `IFA_TARGET_NETNSID` | +| neigh | 4 / 13 | 262 | `NDA_IFINDEX`, `NDA_MASTER` (neigh dump filters); `NDA_VLAN`, `NDA_PORT`, `NDA_VNI`, `NDA_NH_ID`, `NDA_FDB_EXT_ATTRS` (`bridge fdb`, W5) | `NDA_CACHEINFO`, `NDA_PROBES` | +| link | 19 / 56 | 2548 | `IFLA_MASTER`, `IFLA_AF_SPEC` (addrgenmode), `IFLA_NET_NS_FD`, `IFLA_CARRIER`, `IFLA_LINK_NETNSID`, `IFLA_PROTO_DOWN` and `_REASON`, `IFLA_XDP`, `IFLA_TARGET_NETNSID`, `IFLA_NEW_IFINDEX`, `IFLA_PROP_LIST`/`IFLA_ALT_IFNAME` (altname), `IFLA_PARENT_DEV_NAME`, `IFLA_VFINFO_LIST` | 23, mostly ones the kernel only reports (`IFLA_PROMISCUITY`, the carrier counters, `IFLA_MIN_MTU`/`MAX_MTU`, `IFLA_PERM_ADDRESS`, `IFLA_TSO_MAX_*`, …), plus `IFLA_MAP`, `IFLA_WEIGHT`, `IFLA_NET_NS_PID`, `IFLA_VF_PORTS`, `IFLA_PORT_SELF` and `IFLA_PACING_OFFLOAD`, which the kernel takes and iproute2 7.2.0 has no keyword for | + +Whether ip has a keyword was checked by searching iproute2's `ip/`, `lib/` +and `bridge/` for each name where an attribute is added. A plain +line-by-line search for `addattr…(…NAME` missed four that are added over +two lines or through a helper: `RTA_ENCAP` (via `rta_nest`), +`IFLA_PROP_LIST`, `IFLA_PROTO_DOWN_REASON` and `IFLA_VFINFO_LIST`. + +**What the report showed besides the gaps** ("sent outside the policy", +each checked against the kernel source): + +- **`rtm_ipv4_policy` has no `RTA_VIA`**, and `rtm_ipv6_policy` no `RTA_DST` + or `RTA_SRC`. The kernel reads them without policy validation + (`fib_gw_from_via`; `rtm_to_fib6_config` copies the prefixes itself). +- **`fib_rule_policy` has no `FRA_DST` or `FRA_SRC`.** Each family's + `configure` hook checks them. +- **ip sends `IFA_LABEL` and `IFA_BROADCAST` on IPv6**, which + `ifa_ipv6_policy` doesn't name and the kernel ignores (W3a). +- **IPv6 requests carry `RTA_CACHEINFO`.** These are `ip -6 route flush`'s + deletes, which echo back the dumped routes' attributes, cache info + included (18 mined fib rows). + +**Tests:** `TestUapiEnum`, `TestPolicyNames`, `TestFindPolicy`, +`TestLoadPolicies`, `TestCovTally` and `TestWriteCoverage`, on fixture +trees and hand-built messages, since the Linux tree isn't available to the +hermetic tests. + +**Mutation:** 16 of 16 mutants killed, each by a relevant test. One +survived the first pass: every test attribute was 8 bytes long, so +dropping the walk's alignment to four bytes changed nothing. +`TestCovTally` now has an odd-length attribute followed by another. + +Next is W4b: rows for the keywords in the table's fourth column. + +##### W4b: `ip route get`, for the route attributes no write sends + +Four of route4's seven gaps and five of route6's seven are lookup +attributes. `nlcapgen matrix -family routeget -seed 1 -random 40` writes +`nix/microvms/workbench/routeget.rows.jsonl` from two spaces in +`tools/nlcapgen/matrix_routeget.go`. Each row runs `routeSetup`'s namespace +plus a default route per family, so every destination resolves. There is +no readback: the answer is in ip's own capture. + +| axis | values (boundary values in bold) | +|---|---| +| dst | connected, via the default route, **/32** or **/128**, `to` | +| from | absent, d0's address | +| dev | absent, `oif d0`, `dev d0` | +| mark | absent, 0x10, **0** (ip sends no `RTA_MARK`), **4294967295** | +| uid | absent, 1000, **0** | +| ipproto | absent, tcp, udp, the family's ICMP | +| ports | absent, sport, dport, both, **dport 65535** | +| tos (v4) / flowlabel (v6) | absent, 0x10 or `dsfield 0x08` / 0x12345, **0xfffff** | +| flags | absent, fibmatch, notify (v4) | + +161 rows (routeget4 81, routeget6 80): 7 positive, 42 boundary, 101 corner +and 11 negative. The first run's 55 failures were two kernel rules the +spaces hadn't modeled: + +- **Only TCP, UDP and the family's own ICMP are taken.** + `rtm_getroute_parse_ip_proto` (`net/ipv4/netlink.c`) refuses anything + else with EOPNOTSUPP ("Unsupported ip proto"), including ICMP in an IPv6 + lookup and ICMPv6 in an IPv4 one. These are now bad values. +- **An IPv6 lookup takes `RTM_F_FIB_MATCH` alone.** + `inet6_rtm_valid_getroute_req` refuses `notify` (EINVAL, "Invalid flags + for get route request"). That is the strict check, so ip enables strict + checking for gets. A device ip can't resolve is refused in its + `RTM_GETLINK` (ENODEV), before any lookup is sent. + +**Oracles** (`pkg/xtcpnl/xtcpnl_ws_routeget_test.go`): + +- **O3** rebuilds the lookup: the `rtmsg`, with dst and src lengths at + full width, `RTM_F_LOOKUP_TABLE` on IPv4, plus fibmatch and notify. Then + every attribute's bytes. The ports and flow label are network order + (`get_be16`/`get_be32`), the mark and uid host order, `RTA_MARK` absent + for mark 0, and `RTA_OIF` as d0's index. `x/sys/unix` has no + `RTA_FLOWLABEL`, so the test names it as `RtaNhID + 1`. O3 checked 154 + lookups. +- **O4** holds the kernel's answer (150 rows). It must leave through + `d0`. Without fibmatch it is the destination at full length; with it, + the FIB entry that matched, which is shorter and holds the destination, + or the default route with no `RTA_DST`. +- **O5 doesn't apply:** a lookup isn't a write. +- **Coverage:** route4 rises from 12/19 to 16/19 and route6 from 12/19 to + 17/19. What remains is `RTA_FLOW` (realms) and `RTA_ENCAP`/`_TYPE`, + which belong in the route write spaces. Adding values there regenerates + every route row, so it is left for its own step. +- **Determinism:** a second run of all 161 rows, on the same workbench + boot, gave the same status, rc and message types on every row. + +**Tests:** `TestBuildRouteGet` and `TestMatrixRouteGetRows` cover the +generator. `TestRouteGetWantFromAxes`, `TestRouteGetRequestProblem`, +`TestRouteGetReplyProblem` and `TestPrefixHolds` cover the oracles. + +**Mutation:** 17 of 17 mutants killed, each by a relevant test. Two +survived the first pass: + +- the setup test compared the setup with the constant that built it, so + dropping the default routes went unseen. It now names them; +- no prefix test cut through a byte where the byte and the bits disagree. + `TestPrefixHolds` now has a /20 holding 192.0.31.9. + +**Size:** the rows add 267 KB of file bytes to `ws/`, 16.2 MB in all. + +**Budget raised to 64 MB (2026-11-01):** at 16.2 MB of 20 MB, the corpus +had room for about one more W3-sized family, and W5 adds five. The budget +in `TestWsSizeBudget` is now 64 MB per kernel version. Each version has its +own `ws/`, so W7's replays don't count against it. Packing a family into +one pcapng stays the fallback if a version nears the new limit. + +##### W4c: the link keywords no row sent + +The link family's fourth-column gaps. `nlcapgen matrix -family linkattr +-seed 1 -random 20` writes `nix/microvms/workbench/linkattr.rows.jsonl` +from three spaces in `tools/nlcapgen/matrix_linkattr.go`: + +| space | device | axes (boundary values in bold) | +|---|---|---| +| linkattr | `d0`, with a bridge `br0` and a VRF `v0` | master: `master br0`, `vrf v0`, `nomaster`; addrgenmode: eui64, none, random; carrier on/off; netns: `netns $$`, `netns $$ index 100`; xdp: `xdp off`, `xdpgeneric none`, `xdpdrv off` (always last) | +| linkpd | a macvlan `m0` (a dummy has no `change_proto_down`) | protodown on/off; `protodown_reason` 0 on, 0 off, **31 on**; up | +| linkprop | `d0` | `ip link property add`/`del`, and `ip link show` by altname; altname: short, 16 characters, **127 characters**, two at once, one with a slash; `dev d0` or bare `d0` | + +100 rows (linkattr 52, linkprop 28, linkpd 20): 18 positive, 5 boundary, +59 corner and 18 negative. Two are derived: + +- `linkattr/xdp-not-last` has `xdp off carrier on`; +- `linkattr/parentdev` is `ip link set dev d0 parentdev br0`. iplink_parse sends + `IFLA_PARENT_DEV_NAME` on a set too, and the kernel only ever dumps it + (`rtnl_fill_ifinfo`), so the set succeeds and changes nothing. + +`linkprop/del-absent` (ENOENT) and `linkprop/dup-1` (EEXIST) are derived +too. + +The first runs' failures were all iproute2 parsing rules the spaces +hadn't modeled: + +- **`xdp off` must be the last argument.** Otherwise `xdp_parse` hands + `off` to `bpf_parse_and_load_common` as a program mode (`What mode is + "off"?`). +- **`carrier`/`protodown` with a bad value exit 22.** That is + `parse_on_off`'s -EINVAL, not the usual 255. +- **`index` needs `netns`** (rc 255, "index can be used only when creating + devices or when moving device to another netns"). +- **The kernel decides the rest:** + - `xdpdrv off` on a dummy succeeds; + - a slash in an altname is taken (`netdev_name_node_alt_create`); + - `master nosuch` is ip's lookup refused with ENODEV; + - `protodown on` on a dummy gives EOPNOTSUPP; + - `protodown off` with a reason on gives EBUSY (`do_set_proto_down`, + "Cannot clear protodown, active reasons"). + +**Two iproute2 print bugs** turned up in `ip -d link show` (`print_proto_down`, +`ip/ipaddress.c`). Both are recorded here only: + +- **The reason is read with `rta_getattr_u8`**, though + `IFLA_PROTO_DOWN_REASON_VALUE` is a u32. Reasons 8–31 never print: the + `31 on` rows show no `protodown_reason`, while O4 finds 0x80000000 in + the kernel's dump. +- **No space precedes `protodown_reason <…>`**, so it runs into the + address before it (`brd ff:ff:ff:ff:ff:ffprotodown_reason <0>`). + +**Oracles** (`pkg/xtcpnl/xtcpnl_ws_linkattr_test.go`): + +- **O3 for linkattr and linkpd** extends the W3c set model with: + - `IFLA_MASTER` as the looked-up index, 0 for `nomaster`; + - `IFLA_CARRIER` and `IFLA_PROTO_DOWN` as u8; + - `IFLA_NEW_IFINDEX`, `IFLA_LINK_NETNSID` and the + `IFLA_PARENT_DEV_NAME` string; + - the nests: addrgenmode in `IFLA_AF_SPEC`'s `AF_INET6`, `IFLA_XDP`'s fd + −1 and flags, and the reason's mask and value; + - the namespace fd `netns $$` opened, taken from the request (any + descriptor past stderr). + + It checked 63 requests. +- **O4 for linkattr and linkpd** holds the dumped device's master, + addrgenmode and carrier, and its protodown and reason, which are 0 when + not asked for. `ip link show dev m0` first looks m0 up and gets a + name-only answer, so the last reply about the device is the one read. + It checked 57 round trips. +- **O3 for linkprop:** + - an add carries `NLM_F_EXCL|NLM_F_CREATE|NLM_F_APPEND`; + - the names are NUL-terminated `IFLA_ALT_IFNAME`s in `IFLA_PROP_LIST`; + - a show sends two `RTM_GETLINK`s naming the device by `IFLA_IFNAME`, + or by `IFLA_ALT_IFNAME` when `check_ifname` refuses the name (16 or + more characters, a slash or space, `.` or `..`). The second one has + `AF_PACKET` and no index. + + It checked 27 requests. +- **O4 for linkprop:** after an add or show, d0 holds every name; after a + del, none. It checked 24 round trips. +- **O5:** + - linkattr has 2 same and 50 refused; + - linkpd has 1 same and 19 refused. goip has none of these keywords, + which adds addrgenmode, vrf, protodown and its reason, carrier, xdp, + netns, index, link-netnsid and parentdev to its list; + - linkprop has 19 same: goip's altname writes match ip's, and the 9 + shows aren't writes. +- **Coverage:** link rises from 19/56 to 31/56 (`ws-coverage.txt`). The + tally now counts `RTM_NEWLINKPROP` and `RTM_DELLINKPROP` as link + requests, since `rtnl_linkprop` parses them with `ifla_policy`. Of the + 25 left, iproute2 7.2.0 sends only two: + - `IFLA_TARGET_NETNSID`, from `link-netnsid` with `link`, which needs a + peer namespace with an nsid; + - `IFLA_VFINFO_LIST` (`vf`, W5's netdevsim). + + It never sends `IFLA_NET_NS_PID`: `netns_get_fd` turns a pid into + `/proc/PID/ns/net` and sends the fd. +- **Determinism:** a second run of all 100 rows, on the same workbench + boot, gave the same status, rc and message types on every row. + +**Tests:** + +- The generator: `TestBuildLinkAttr`, `TestDeriveLinkAttrCorners`, + `TestLinkAttrSetups`, `TestLinkPDJudge`, `TestBuildLinkPD`, + `TestBuildLinkProp`, `TestDeriveLinkPropAbsent` and + `TestMatrixLinkAttrRows`. +- The oracles: `TestLinkAttrWantFromAxes`, `TestLinkNetnsFD`, + `TestLinkProtoDown`, `TestLinkAttrDumpProblem`, `TestLinkPropAlts`, + `TestLinkPropRequestProblem`, `TestLinkShowNameAttr`, + `TestLinkShowProblem` and `TestLinkPropDumpProblem`. + +**Mutation:** 30 of 30 mutants killed, each by a relevant test. Four +survived the first pass, and each now has a row: + +- no generator test looked at reason 31's mask; +- no O4 row had a carrier that was present but wrong; +- no show row had three requests; +- `TestWsO5HdrLen` lacked the property types. Their bodies match byte for + byte, so comparing them whole hid the missing header length. + +**Size:** the rows add 644 KB to `ws/`, 16.9 MB of 64 MB in all. + +##### W5a: `bridge fdb` + +W4's report counted no `AF_BRIDGE` neighbor writes: `ip neigh` never +sends `NDA_VLAN`, `NDA_PORT`, `NDA_VNI`, `NDA_IFINDEX`, `NDA_SRC_VNI`, +`NDA_NH_ID` or `NDA_FDB_EXT_ATTRS`, and `bridge fdb` sends all of them. +`nlcapgen matrix -family fdb -seed 1 -random 40` writes +`nix/microvms/workbench/fdb.rows.jsonl` from two spaces in +`tools/nlcapgen/matrix_fdb.go`: + +| space | devices | axes (boundary values in bold) | +|---|---|---| +| fdbbr | a VLAN-filtering bridge `br0` with a dummy port `d0`, VLAN 10 on both | verb: add, append, replace, del; MAC: unicast, unicast after `to`, multicast, zero; dev: port or bridge; `self`, `master`, both or neither; state: none, permanent, static, dynamic, local, temp; sticky, extern_learn, use, router; vlan: none, 1, 10, **4094**, 65535; `activity_notify`, `norefresh`, `activity_notify inactive`, `inactive` alone | +| fdbvx | a VXLAN `vx0` (VNI 100, port 4789, nolearning) over `d1`, with nexthops 1 (fdb), 10 (an fdb group of 1) and 2 (not fdb) | remote: `dst` v4 or v6, `nhid` 10, 1, 2 or 99, none; port: none, 4790, **0**, **65535**, `0x12b6`; vni: none, 200, **0**, **16777215**; src_vni: none, 100, 300; `via d1`; state | + +199 rows (fdbbr 99, fdbvx 100): 3 positive, 6 boundary, 34 corner and +156 negative. A del's pre step is its own add, when that add would be +accepted. Derived rows add a duplicate add (EEXIST), a delete of +something absent (ENOENT) and the two `twice-*` rows below. + +**bridge, unlike ip:** + +- **It exits 255 on a kernel refusal**, since `fdb_modify` returns -1. +- **Every command dumps links first** (`ll_init_map`, `bridge/fdb.c:948`), + so a refused argument is `ack-then-fail`, never zero messages. An + unknown `dev` or `via` is the kernel's ENODEV to `ll_link_get`. +- **`fdb_modify`'s defaults:** + - with neither `self` nor `master` it sets `NTF_SELF`; + - the state starts at `NUD_NOARP`, so no keyword means permanent (192), + `static` is 66 and `dynamic` is 2; + - `vlan` is `atoi` into a short and is sent only if not negative, so + `vlan 65535` sends nothing and `vlan ten` sends VLAN 0; + - `port 0` isn't sent; + - `nhid` is checked against `dst`, `port` and `vni` only, so `via` with + `nhid` reaches the kernel (EINVAL). + +**The kernel:** `rtnl_fdb_add` and `rtnl_fdb_del` parse with a NULL +policy (`net/core/rtnetlink.c:4822` and `4936`). They refuse VLAN 0 and +4095 (EINVAL). With neither path taken the answer is EOPNOTSUPP, which +Go names ENOTSUP, as the rows do. The paths are: + +- `master` on a bridge port, which is `br_fdb_add`. It refuses (EINVAL) + the zero MAC, a VLAN the port lacks, `use` or a non-permanent entry + towards the bridge, sticky with permanent, and `inactive` without + `activity_notify`; +- `self` on the dummy, which is `ndo_dflt_fdb_add`. It takes only + permanent entries and no VLAN; +- the VXLAN's `vxlan_fdb_add`. It refuses (EINVAL): + - no `dst` and no `nhid`; + - `nhid` with anything else; + - a dynamic `nhid`; + - an `nhid` that is missing, not fdb or not a group. + + An IPv6 `dst` on the IPv4 VXLAN is EAFNOSUPPORT, and a replace of a + multicast or zero MAC is EOPNOTSUPP. + +**A kernel quirk** (recorded here only): + +- `vxlan_fdb_alloc` keys an entry by its `src_vni`, but on a VXLAN + without `external` (`VXLAN_F_COLLECT_METADATA`), `vxlan_find_mac_rcu` + always looks up under the device's own VNI + (`drivers/net/vxlan/vxlan_core.c`). +- So an entry added with a foreign `src_vni 300` can't be found: + - its delete is ENOENT (the del rows with `src_vni 300` expect that); + - a second identical replace finds nothing, inserts the same key + again, and the hash refuses it with EEXIST (`fdbvx/twice-other`). The + same pair under the device's own VNI succeeds (`fdbvx/twice-own`); + - the dump still lists the entry, without its `src_vni`. + +**Oracles** (`pkg/xtcpnl/xtcpnl_ws_fdb_test.go`): + +- **ParseNeigh** now decodes `NDA_PORT` (big-endian), `NDA_VNI`, + `NDA_SRC_VNI`, `NDA_IFINDEX`, `NDA_NH_ID` and `NDA_FDB_EXT_ATTRS`'s + `NFEA_ACTIVITY_NOTIFY` and `NFEA_DONT_REFRESH`. A short attribute is + skipped, and so is a nest that overruns. A repeated attribute's last + copy wins. +- **O3:** + - the request has `AF_BRIDGE`, the looked-up index, the state and + flags; + - its attributes are in `fdb_modify`'s order, at their widths; + - the decoded values equal the axes. + + An `ack-then-fail` row must send no fdb request. O3 checked 190 + requests. +- **O4:** after an accepted add, the device's dump has the entry + (MAC, VLAN and, on vx0, the dst, nhid, port, VNI and via); after a + del, it is gone. A port of 4789 or VNI of 100 is the device's own, + which the dump leaves out. O4 checked 43 round trips. +- **O5 doesn't apply:** goip implements `ip`, not `bridge`. +- **Coverage:** no policy judges these writes, so `nlcapgen coverage` + counts them on a line of their own (190 requests) rather than as + neigh, which stays at 4/13. The `NDA_*` attributes above are sent + only by these writes. +- **Determinism:** a second run of all 199 rows, on the same workbench + boot, gave the same status, rc and message types on every row. + +**Tests:** + +- The generator: `TestFdbBrJudge`, `TestFdbVxJudge`, `TestBuildFdb`, + `TestFdbVxTwice`, `TestDeriveFdbDupGone`, `TestMatrixFdbRows`, and new + `TestCovTally` and `TestWriteCoverage` rows. +- The oracles: `TestFdbWantFromAxes`, `TestFdbRequestProblem`, + `TestFdbDumpHas`, `TestFdbAckFailProblem`, `TestFdbRoundTripProblem`, + and seven new `TestParseNeigh` rows. + +**Mutation:** 29 of 29 mutants killed, each by a relevant test. Three +survived the first pass: + +- **ParseNeigh kept the first `NFEA_ACTIVITY_NOTIFY`**, where + `nla_parse` and `parse_rtattr` keep the last. It now keeps the last, + and a row repeats the attribute. +- **No hand-built row broke two oracle checks:** the `ack-then-fail` + check, and O4's presence check. Each is now a function with its own + table (`TestFdbAckFailProblem`, `TestFdbRoundTripProblem`). + +Two mutants that didn't compile were rewritten and rerun. + +**Size:** the rows add 563 KB to `ws/`, 17 MB of 64 MB in all. + +##### W5b: `bridge mdb` + +W4's report covered no MDB writes. `bridge mdb` is the only iproute2 +command that sends `RTM_NEWMDB` or `RTM_DELMDB`, and the only one that +dumps with `RTM_GETMDB`. `nlcapgen matrix -family mdb -seed 1 -random 40` +writes `nix/microvms/workbench/mdb.rows.jsonl` from two spaces in +`tools/nlcapgen/matrix_mdb.go`: + +| space | devices | axes (boundary values in bold) | +|---|---|---| +| mdbbr | a VLAN-filtering bridge `br0` with a dummy port `d0`, VLAN 10 on both; a lone dummy `d9`; a second bridge `br1` with port `d2` | verb: add, replace, del; port: `d0` or `br0` (a host join); group: IPv4, IPv6, L2; state: none, permanent, temp; vid: none, 1, 10, **4094**; src: none, IPv4, IPv6; filter_mode: none, exclude, include; source_list: none, one, two; proto: none, static, bgp, 200 | +| mdbvx | a VXLAN `vx0` (VNI 100, port 4789, nolearning) over `d1` (192.0.2.10/24) | verb; group: IPv4, 0.0.0.0, IPv6, `::`; state: permanent or none; dst: IPv4, IPv6, none; dst_port: none, 4790, **0**, **65535**; vni: none, 200, **0**, **16777215**; src_vni: none, 100, 300; `via d1`; src; filter_mode; source_list; proto | + +177 rows (mdbbr 90, mdbvx 87): 5 positive, 6 boundary, 18 corner and +148 negative. As in W5a, a del's pre step is its own add, when that add +would be accepted. Derived rows add a duplicate add (EEXIST), a delete of +something absent (EINVAL on the bridge, ENOENT on the VXLAN) and +`mdbbr/del-all-vlans` (below). + +**bridge's side** (`bridge/mdb.c` `mdb_modify`): + +- add is `NLM_F_CREATE|NLM_F_EXCL`, replace `NLM_F_CREATE|NLM_F_REPLACE`, + and del is `RTM_DELMDB` with no flags. +- `MDBA_SET_ENTRY` is a 28-byte `struct br_mdb_entry` after an 8-byte + `struct br_port_msg`. `permanent` reaches the state only on + `RTM_NEWMDB`. The VLAN is `atoi` into a short and always sent, so + `vid 65535` is sent as 65535. +- `MDBA_SET_ENTRY_ATTRS` is sent iff any of src, filter_mode, + source_list, proto, dst, dst_port, vni, src_vni or via is given. Its + attributes come in that order. `dst_port` is a host-order u16, and + `dst_port 0` is sent. The VNI isn't range-checked, so `vni 16777216` + reaches the kernel, which answers ERANGE. +- As in `bridge fdb`, the links are dumped first (`ll_init_map`), so a + refused argument is `ack-then-fail` and exits 255. + +**The kernel:** + +- `rtnl_validate_mdb_entry` (`net/core/rtnetlink.c`, policy + `mdba_policy`) refuses (EINVAL): + - a zero port index; + - an IPv4 group that is neither multicast nor 0.0.0.0, or that is local + multicast; + - `ff02::1`; + - an L2 group that isn't multicast; + - a state other than 0 or 1; + - VLAN 4095 or more. + + A device without `ndo_mdb_add`, such as a dummy, is EOPNOTSUPP. + `proto kernel` is under the policy's minimum (ERANGE). +- **The bridge** (`net/bridge/br_mdb.c`, `br_mdb_config_init` and + `br_mdb_config_attrs_init`) refuses (EINVAL): + - a port that isn't one of its own; + - the 0.0.0.0 group; + - a source of the wrong family, or on an L2 group; + - a filter mode or source list on a host group or on an entry that is + no (*, G); + - a source list without a filter mode; + - INCLUDE with an empty list; + - a protocol on a host group. + + `br_mdb_add` then refuses flags on a host group, a host join with a + source, and an L2 entry that isn't permanent. A VLAN the port lacks + (4094) is taken as given. +- **The VXLAN** (`drivers/net/vxlan/vxlan_mdb.c`) refuses (EINVAL): + - a port other than itself; + - an add or replace that isn't permanent; + - a VLAN; + - a missing `dst`; + - a source on the all-zeros group; + - a filter mode or source list on an (S, G) entry; + - a source list without a mode; + - INCLUDE with an empty list. + + It takes the 0.0.0.0 and `::` groups, its catch-all. A delete that + finds nothing is ENOENT; a duplicate add is EEXIST. + +**A kernel quirk** (recorded here only): on a VLAN-filtering bridge, a +delete without a VLAN loops over the port's VLANs, and `br_mdb_del` +returns the last one's result. After an add in VLAN 1 only, a delete +with no VLAN removes that entry but reports VLAN 10's EINVAL +(`mdbbr/del-all-vlans`). + +**Dumps differ by device:** + +- The bridge answers a dump with `RTM_GETMDB` + (`net/bridge/br_mdb.c:391`), and the VXLAN with `RTM_NEWMDB` + (`drivers/net/vxlan/vxlan_mdb.c:292`). +- The bridge dumps an entry's filter mode and source list only under + IGMPv3 or MLDv2; `br0` runs the defaults (IGMPv2, MLDv1). Its dump + also holds the kernel's own MLD host joins (`ff02::6a` and the + solicited-node groups). +- The VXLAN leaves out a port of 0 or its own 4789, a VNI that is its + own (100), and the source VNI unless it is `external`. +- The dump's attribute numbers (`MDBA_MDB_EATTR_*`) differ from the + request's (`MDBE_ATTR_*`). + +**Oracles:** + +- **`pkg/xtcpnl/xtcpnl_mdb.go`:** `ParseMdbEntry`, `ParseMdbRequest` and + `ParseMdbDump`, which decode both attribute numberings. +- **O3** (`pkg/xtcpnl/xtcpnl_ws_mdb_test.go`): + - the request is rebuilt from the row's axes alone: the type, the + flags, the entry with the looked-up indexes, and the nest in + `mdb_modify`'s order; + - the decoded request must equal it. + + O3 checked 166 requests. An `ack-then-fail` row must send none. +- **O4:** + - After an accepted add, the device's dump has the entry: in each of + VLANs 1 and 10 when none was given, with the state, protocol and, on + `vx0`, the dst, mode, source list, port, VNI and via. + - After a del, it is gone. Extra entries are ignored. + + O4 checked 28 round trips. +- **O5 doesn't apply**, as in W5a. +- **Coverage:** `nlcapgen coverage` gains an `mdb` family + (`mdba_policy`, 2/2) and an `mdbe` family for the nest + (`vxlan_mdbe_attrs_pol`, 9/9). A family can now judge a nested + attribute's payload, and one request can count towards two families. +- **Universe:** `RTM_GETMDB` is captured, so its exclusion in + `xtcpnl_universe_test.go` is gone (four exclusions remain). +- **Determinism:** a second run of all 177 rows, on the same workbench + boot, matched on every row. + +**Tests:** + +- The parsers: `TestParseMdbEntry`, `TestParseMdbRequest` and + `TestParseMdbDump`. +- The generator: `TestMdbBrJudge`, `TestMdbVxJudge`, `TestBuildMdb`, + `TestMdbBrAllVlans`, `TestDeriveBridgeDupGone`, `TestMatrixMdbRows`, and + five new `TestCovTally` rows. +- The oracles: `TestMdbWantFromAxes`, `TestMdbRequestProblem`, + `TestMdbRoundTripProblem` and `TestWsMdbDump`. + +**Mutation:** 40 of 40 mutants killed, each by a relevant test. Three +survived the first pass, and one didn't compile (it was rewritten and +rerun): + +- **No test pinned the VXLAN's gone-row errno** (ENOENT, where the + bridge's is EINVAL). `TestMatrixMdbRows` now checks both. +- **No hand-built row broke two O4 checks on the VXLAN:** its source list, + and an (S, G) entry dumping as INCLUDE. `TestMdbRoundTripProblem` has a + row for each. + +**Size:** the rows add 561 KB to `ws/`, 18 MB of 64 MB in all. + +##### W5c: `ss` + +`ss` is iproute2's one sock_diag client. It reads each inet table with +`SOCK_DIAG_BY_FAMILY` on `NETLINK_SOCK_DIAG`. Its filter (`sport ge :1024`, +`src 127.0.0.0/8`, `autobound` and so on) is compiled to bytecode that the +kernel audits and runs per socket. No earlier row captured this protocol. +The workbench now captures it: `xtcp2-nlcap` takes `--proto sock_diag`, +whose stop sentinel is `ip link show dev lo` (filtered to protocol 0), +because the default `ss -x` sentinel is itself sock_diag. + +`nlcapgen matrix -family ss -seed 1 -random 40` writes +`nix/microvms/workbench/ss.rows.jsonl` from one space in +`tools/nlcapgen/matrix_ss.go`: + +| axis | values (boundary values in bold) | +|---|---| +| proto | `-t`, `-u`, `-w`, `-M` (MPTCP), `-S` (SCTP) | +| family | both, `-4`, `-6` | +| ext | none, `-m`, `-i`, `--tos`, `-mi` | +| state | default, `-a`, `-l`, `state established`, `exclude time-wait`, `state connected`, two `state` words, **`-B`** | +| filter | none; each port operator in word and symbol form, with **`:0`**, **`:65535`**, `:65536` and `:-1`; IPv4 and IPv6 hosts and prefixes, **`/0`**, with and without a port; or, and, not, implicit and, parentheses; `autobound`; `fwmark` with and without a mask; `cgroup`; `dev lo`; `src localhost` | + +383 rows: 59 positive, 38 boundary, 268 corner and 18 negative. Every row's +namespace holds the same sockets, opened by a pre step (`ssHolder`): +- TCP listeners on 127.0.0.1:22, [::1]:80, 127.0.0.1:8080, [::]:443 + (dual-stack) and 127.0.0.1:9000 (`SO_MARK` 0x10); +- two established pairs, one of them v4-mapped through the dual-stack + listener; +- UDP sockets on 127.0.0.1:53 and [::1]:5353, plus a connected one; +- a raw ICMP socket. + +The namespace's port range is one port wide, so both autobound connects +take 45000. A forked child holds the sockets for 10 s. + +**ss's side** (`misc/ss.c`): + +- `inet_show_netlink` sends one dump per family, AF_INET then AF_INET6 + (only one with `-4` or `-6`). +- `sockdiag_send` writes `idiag_states` in host order. + - The collector's own requests store a byte-swapped constant + (`pkg/xtcp/init.go`) that they write big-endian. That is why + `DeserializeInetDiagReqV2` reads the field big-endian; + `ParseInetDiagRequest` reads it the kernel's way. + - With no state option, the mask is the tables' defaults ORed + together: SS_CONN for tcp, mptcp and sctp, but only SS_ESTABLISHED + for udp and raw (`ss.c:276`, `280`). `-4` and `-6` OR in SS_CONN too + (`filter_af_set`). +- `-m`, `-i` and `--tos` set ext bits 0x41, 0x0e and 0x30. +- MPTCP's protocol, 262, is stored truncated in the u8 (6) and sent + again in `INET_DIAG_REQ_PROTOCOL`. +- `-4` and `-6` also set the family addresses are parsed in, so an + address of the other family is refused (rc 1). +- `dev` compiles to nothing (`ss.c:2059`). Any `and` or `or` holding it + sends no bytecode, and ss filters the replies itself. +- A `cgroup` term makes ss probe kernel support first + (`ssfilter_check.c`): AF_INET, TCP, no states, `CGROUP_COND` 0. + +**iproute2 bugs** (recorded here only; the rows assert they are still +there through a `bug` fact): + +- **`src localhost` tests only the first address.** + `ssfilter_bytecompile` builds every address of a name from the first + one (`a->addr`, not `b->addr`, `ss.c:1945`). The request carries two + `S_COND`s for 127.0.0.1, and `[::1]:80` is never listed. With `-4` or + `-6` the name resolves to one address, and the rows expect no bug. +- **`sport ge :65536` passes every socket.** A port comparison's operand + is the u16 `no` field (`ss.c:1987`), so 65536 wraps to 0. + `sport = :65536` is a hostcond with an int port, which correctly + matches nothing. +- **`sport = :-1` passes every socket.** `get_long` takes -1, which a + hostcond reads as any port. +- **Bytecode padding leaks heap.** The bytecode buffer is `malloc`ed and + never cleared, so a hostcond's 2 padding bytes carry heap contents into + the request (seen as `87 e2`). The oracles never compare them. +- **The cgroup probe runs for every `cgroup` term.** + `ssfilter_is_supported` caches the result in a copy of the table entry, + so `cgroup A or cgroup A` sends two probes (the `cgroup2` rows). +- **`cgroup /` doesn't name the cgroup2 root.** `get_cgroup2_id` tries + the path as given first (`lib/fs.c:145`). In the guest `/` has no file + handle, so ss refuses it; on a host whose root has one, it would take + that id. The rows use `cgroup /sys/fs/cgroup`. `ip netns exec` mounts a + fresh sysfs without cgroup2, so a cgroup row mounts it first, in the + exec's private mount namespace. +- **A prefix outside the brackets is refused.** `[2001:db8::]/32` reads + `/32` as the port (rc 1, the `v6slash` row); the prefix goes inside, + `[2001:db8::/32]`. + +**The kernel** (`net/ipv4/inet_diag.c` unless noted): + +- `inet_diag_bc_audit` (`:728`) checks: + - each op's yes and no: at least its length, at most 4 past the end, + a multiple of 4; + - every jump target is an op boundary (`valid_cc`); + - a hostcond's family and prefix length; + - `MARK_COND` needs CAP_NET_ADMIN (EPERM). + + Every bytecode ss sent passes it. +- `inet_diag_bc_run` (`:465`) matches an AF_INET condition against a + v4-mapped AF_INET6 socket, so `src 127.0.0.1` lists the mapped + `[::ffff:127.0.0.1]:443`. +- An accepted socket keeps its listener's userlocks minus + `SOCK_BINDPORT_LOCK` (`net/core/sock.c:2570`), so `autobound` lists + both ends of an established pair. +- `raw_diag`'s dump ignores `idiag_states`; ss drops the states it didn't + ask for itself (`ss.c:4330`). +- `INET_DIAG_MARK` is sent only to a CAP_NET_ADMIN reader (`:128`). + +**Oracles:** + +- **`pkg/xtcpnl/xtcpnl_inet_diag_bc.go`:** + - `ParseInetDiagRequest` decodes the request; + - `InetDiagBcAudit` and `InetDiagBcRun` port the kernel's audit and + run. +- **O3a** (`pkg/xtcpnl/xtcpnl_ws_ss_test.go`): each request matches the + row's facts. It checks the family order, the protocol (and its u8), + the protocol attribute, the states, the ext bits, and whether there is + bytecode. Any bytecode must pass the audit. Cgroup probes come first + and must match the probe's shape. +- **O3b:** the bytecode is run over 143 probe sockets: + - the holder's sockets; + - a grid of ports, addresses, v4-mapped addresses, locks, marks, + devices and cgroups. + + Each verdict must equal the filter the user meant: the row's `pred` + fact, a prefix notation the test evaluates on its own. On a `bug` row + they must disagree somewhere. + + O3a and O3b checked 639 requests. +- **O4:** each reply lists a holder socket in a requested state that + passes the filter, with its mark. Every holder socket that passes + is listed. The filter is the pred, or on a bug row the bytecode as + sent; on a row with no bytecode it is no filter. O4 checked 607 round + trips. The 32 cgroup requests are skipped, because the holder's cgroup + id is unknown to the model. +- **O5 doesn't apply:** goip has no sock_diag. +- **Coverage:** inet_diag requests have no nla policy, so `nlcapgen + coverage` counts their bytecode ops on a line of their own: 10 of 14 + (667 requests). ss never sends `NOP`, `DEV_COND`, `S_EQ` or `D_EQ`: + `dev` compiles to nothing, and `sport = :N` is a hostcond, not an + `S_EQ`. +- **Determinism:** a second run of all 383 rows, on the same workbench + boot, matched on every row. + +**Tests:** + +- The parser: `TestParseInetDiagRequest`, `TestInetDiagBcAudit`, + `TestInetDiagBcRun` and `TestBitstringMatch`. +- The generator: `TestBuildSs`, `TestSsFamilyExcl`, + `TestRowValidateProto`, `TestMatrixSsRows`, `TestCovAddDiag`, + `TestTallyCorpusSockDiag`, and two new `TestWriteCoverage` rows. +- The oracles: `TestSsParsePred`, `TestSsPredPasses`, + `TestSsWantFromAxes`, `TestSsRequestProblem`, + `TestSsCgroupProbeProblem`, `TestSsRoundTripProblem`, + `TestSsExchangeProblems` and `TestWsSsExchanges`. + +**Mutation:** 47 of 47 mutants killed, each by a relevant test. Six +survived the first pass: + +- **A PROTOCOL attribute longer than 4 bytes wasn't tested.** + `TestParseInetDiagRequest` now has an 8-byte row. +- **Four oracle paths were exercised only by the corpus:** the family + order, an O3b disagreement, a bug that is gone, and the cgroup probe's + shape. A clean corpus never trips them. They moved into + `ssExchangeProblems`, and `TestSsExchangeProblems` holds hand-built + rows that break each one. +- **Nothing read a real sock_diag capture through the coverage tally.** + `TestTallyCorpusSockDiag` does. + +**Size:** the rows add 1.5 MB to `ws/`, 18 MB of 64 MB in all. + +##### W5d: `ethtool` + +`ethtool` 7.1 talks to the kernel's `ethtool` generic netlink family. It is +the first workbench client on `NETLINK_GENERIC`. Every row loads +`netdevsim` and makes a fresh device, `eth0`, in its own namespace (the +pre step `ethSetup`; the device id is 900 plus the namespace's number, so +parallel rows never share one). Rows capture with `--proto generic`. + +`nlcapgen matrix -family ethtool -seed 1 -random 20` writes +`nix/microvms/workbench/ethtool.rows.jsonl` from six spaces in +`tools/nlcapgen/matrix_ethtool.go`: + +| space | command | axes (boundary values in bold) | +|---|---|---| +| `ethring` | `-G` (RINGS_SET 16) | each absent or: rx 1, 1024, **4096** (netdevsim's max); tx 1, 256, **4096**; rx-mini **0**, **4096**; rx-jumbo 100; tcp-data-split on, off, auto; hds-thresh **0**, **1024** | +| `ethchan` | `-L` (CHANNELS_SET 18) | combined 1, 2, **4** (the device's queues); rx, tx, other **0** | +| `ethpause` | `-A` (PAUSE_SET 22) | autoneg off; rx, tx on/off | +| `ethfec` | `--set-fec` (FEC_SET 30) | off, auto, rs, baser, llrs, and the corners two modes, auto plus a mode, off plus a mode, mixed case | +| `ethcoal` | `-C` (COALESCE_SET 20) | adaptive-rx/tx, the usecs and frames (plain, irq, low, high), pkt-rate-low/high, sample-interval, cqe-mode-rx/tx, tx-aggr-*, rx-cqe-* | +| `ethshow` | `-g -l -a -c --show-fec -T -k` (their GETs) | none, `--json`, `-I`; `eth0` or `lo` | + +244 rows: 73 positive, 37 boundary, 81 corner and 53 negative. The +negatives are out-of-range values (`4097`, 2^32, 2^32+1, `-1`), a +repeated argument, a missing value, parameters netdevsim doesn't support, +`nosuch0` (ENODEV) and `lo` (EOPNOTSUPP). Each row that sets something +reads it back with the matching show command. `ethShowJSON` adds a +`--json` row of `eth0` for each JSON command the pairwise rows missed. + +**`ethtool`'s side** (`ethtool-7.1/`): + +- Every command looks the family up (`CTRL_CMD_GETFAMILY`). A show + command also dumps the GET operation's policy (`CTRL_CMD_GETPOLICY`, + `netlink/netlink.c:259`) to learn which header flags it takes. `-I` + sets `ETHTOOL_FLAG_STATS` only when that policy allows it + (`netlink.c:289`): PAUSE_GET, FEC_GET and TSINFO_GET. On `-g`, `-l` and + `-c` the flag is silently dropped. These are the corpus's first + `CTRL_CMD_GETPOLICY` captures, so `TestNetlinkUniverse` no longer + excludes it. +- `--set-fec encoding off` sends the link mode name `None` + (`netlink/fec.c:356`). Any other word is upper-cased, so `none` + becomes `NONE`, which names no link mode. The kernel answers + EOPNOTSUPP, "bit name not found" (`net/ethtool/bitset.c:415`). +- `--json` with `-T` is refused before any request (`ethtool.c:6743`). + +**`ethtool` bugs** (recorded here only): + +- **A bare `hds-thresh` reads past argv.** Its parser entry has + `min_argc = 0` (`netlink/rings.c:205`); every other ring parameter + has 1. `ethtool -G eth0 hds-thresh` hands the u32 parser a NULL and + prints `invalid value '(null)' for parameter 'hds-thresh'`. It still + exits 1 before sending anything (the `ethring/ng-017` row). +- **Four coalescing JSON keys differ from their parameters.** The + printer names `rx-frames-low` `rx-frame-low` (`netlink/coalesce.c:72`), + and likewise `tx-frame-low`, `rx-frame-high` and `tx-frame-high` + (`:76`, `:81`, `:85`). So `ethtool --json -c` output can't be fed back + as `-C` arguments by name. The JSON oracle's key table follows the + printer. + +**The kernel and netdevsim:** + +- `tcp-data-split auto` is stored as UNKNOWN, which netdevsim reports as + ENABLED (`drivers/net/netdevsim/ethtool.c:77-78`). The row reads back + on (`rt.tcp-data-split=2`). +- A channel count of 0 for a type whose max is 0 is left out of the + reply (`net/ethtool/channels.c:64-83`). The rows expect it absent. +- netdevsim's `set_coalesce` copies only `struct ethtool_coalesce` + (`netdevsim/ethtool.c:56-63`). Every `kernel_ethtool_coalesce` field + (cqe-mode-rx/tx, tx-aggr-*, rx-cqe-*) is accepted and reads back 0. + Those rows carry `rt.=0`. +- `autoneg on` for pause is EINVAL (`netdevsim/ethtool.c:37`), so + ethtool exits 76. +- `lo` has no ring, channel, pause, coalescing or FEC operation, so + each of those GETs is EOPNOTSUPP. `-T` and `-k` work on it. + +**Oracles:** + +- **The parser** (`pkg/xtcpnl/xtcpnl_ethtool.go`): `ParseEthtool` now + decodes coalescing (`EthtoolCoalesce`, every u32 and u8 attribute; + the two profile nests stay raw). +- **O3** (`pkg/xtcpnl/xtcpnl_ws_ethtool_test.go`): the one SET request + carries the row's device in its header and exactly the attributes its + facts name, each with its value. For FEC, the mode names (as a + no-mask bitset) and auto. A show row's GET has the command, the + device, and the STATS flag exactly when `-I` asks and the policy + allows. A refusal before sending (`ack-then-fail`) sends no SET. +- **O4:** the readback's GET reply holds each attribute set, as set or + as its `rt.` fact, and leaves out the `absent` ones. For FEC, the + configured link-mode bits, auto and the active mode. +- **JSON:** each `--json` key of rings, channels, pause and coalescing + names a reply attribute, and its value equals the reply's. +- O3 checked 231 requests, O4 170 round trips and 47 JSON values. +- **O5 doesn't apply:** goip has no ethtool. +- **Coverage:** `nlcapgen coverage` now reads generic netlink. It names + the family ids from each capture's `CTRL_CMD_NEWFAMILY` replies and + holds each SET to its kernel policy (`ethnl_*_set_policy`). Rings + 12/12, channels 5/5, coalescing 30/32, pause 4/5, FEC 3/3. Never sent: + `RX_PROFILE` and `TX_PROFILE` (ethtool 7.1 has no parameter for them) + and pause `STATS_SRC` (`NLA_REJECT` in a SET). The YNL-generated + header's `X_MAX = (__X_CNT - 1)` entries now evaluate. +- **Determinism:** a second run of all 244 rows, on the same workbench + boot, matched on every row. + +**Tests:** + +- The parser: `TestEthtoolCoalesce`. +- The generator: `TestEthBuild`, `TestEthShowJudge`, `TestEthShowJSON`, + `TestMatrixEthtoolRows`, `TestCovGenlIDs`, `TestCovAddGenl`, + `TestTallyCorpusGenl`, four new `TestUapiEnum` rows and two new + `TestCheckRows` rows (a readback's protocol). +- The oracles: `TestEthWantFromAxes`, `TestEthRequestProblem`, + `TestEthRoundTripProblem`, `TestEthFecProblems`, `TestEthBitIndexes`, + `TestEthShowProblem`, `TestEthJSONProblem`, `TestEthJSONValue` and + `TestEthGenlID`. +- The new captures moved two benchmarks' picks. `ParseGenericNetlinkFamily` + now picks only NEWFAMILY replies (the smallest nlctrl message became a + policy dump). `ParseEthtool` picks only the replies and notifications + it classifies (the largest became a reply it doesn't decode). + +**Mutation:** 45 of 45 mutants killed, each by a relevant test. One +survived the first pass: + +- **`covGenlIDs` would have taken a family id from a request.** Nothing + sent a request carrying `CTRL_CMD_NEWFAMILY`'s command. + `TestCovGenlIDs` now has that row. + +Two mutants didn't compile the first time and were rewritten. While +writing the driver, one more gap turned up before the run: nothing +checked a readback's protocol, so `TestCheckRows` gained its two rows. + +**Size:** the rows add 1.1 MB to `ws/`, 20 MB of 64 MB in all. + +### Captures an outside consumer needs: BIRD's netlink parser + +Counted from the tree on 2026-10-31. The BIRD routing daemon's kernel protocol +(`sysdep/linux/netlink.c` in [BIRD](https://gitlab.nic.cz/labs/bird)) is +being unit-tested and fuzzed with messages taken from these captures, so that +its parser sees real kernel output rather than hand-built bytes. Across every +`.pcap` under `pkg/xtcpnl/testdata/`, de-duplicated by message bytes, there +are 504 `RTM_NEWROUTE`, 46 `RTM_DELROUTE`, 86,585 `RTM_NEWADDR` and 10,210 +`RTM_NEWLINK` (40 link kinds), and 17 routes carry `RTA_MULTIPATH` (10 +`AF_INET`, 6 `AF_INET6`, 1 `RTNL_FAMILY_IPMR`). Seven kernel refusals of a +route request, and 153 refusals carrying extack TLVs, cover BIRD's error-reply +path. Three things BIRD parses are in **no** capture: + +| Missing | What BIRD parses | Capture needed | Guest needs | +|---|---|---|---| +| bridge FDB entries | `RTM_NEWNEIGH`/`RTM_DELNEIGH` with `ndm_family = AF_BRIDGE` (`nl_parse_fdb`); all 414 neighbour messages in the tree are `AF_INET`/`AF_INET6` | `bridge fdb show` on a bridge with static and learned entries, and `bridge fdb add`/`del` under `ip monitor all` | the `bridge` module, the same gap as the **3 bridge / VLAN** row above | +| bridge VLANs | `RTM_NEWVLAN`/`RTM_DELVLAN` (`nl_parse_vlan`); none in the tree | `bridge vlan show` and `bridge vlan add`/`del` on a `vlan_filtering 1` bridge | as above | +| MPLS routes | `rtm_family = AF_MPLS` routes, `RTA_NEWDST` label stacks, and `RTA_ENCAP` with `LWTUNNEL_ENCAP_MPLS` on IP routes, single path and inside `RTA_MULTIPATH`; the tree has no `AF_MPLS` route and no `RTA_ENCAP` at all | `ip -f mpls route show`, `ip -f mpls route add 100 as 200/300 via inet 192.0.2.10 dev X`, `ip route add 198.18.20.0/24 encap mpls 100/200 via 192.0.2.10 dev X`, and a multipath route with a per-nexthop `encap mpls` | `mpls_router` and `mpls_iptunnel` modules, `net.mpls.platform_labels` above the labels used, and `net.mpls.conf..input=1` | + +Routes BIRD installed itself (`rtm_protocol = RTPROT_BIRD`, 12) need no new +capture: the BIRD test rewrites that byte in a captured route. + +#### Plan: B1–B3, the captures, their parsers and the completeness test + +The work order is the usual one: captures, then parsers with table tests and +benchmarks, then a test that every capture is there. + +**B1, captures.** `nix/microvms/scripts/brmpls-write-corpus.exp` holds 36 rows, +run by the link-write capture (`nix run .#microvm-x86_64-link-write-capture`, +or these rows alone with `nix run .#microvm-x86_64-link-write-capture-brmpls`) +into `link-write/brmpls//`. They run last, after +`xtcp2-nlcap modules brmpls mpls_router mpls_iptunnel`: `mpls_router` gives +every device an `AF_MPLS` netconf and stats entry, which no earlier row may +see. Each row rebuilds its namespace: + +- **FDB** (8 rows): `br0` with `vlan_filtering 1`, the veth port `v0`, and + `v0`'s peer `v0p` outside the bridge with a fixed MAC. The learned entry + comes from `v0p`'s DAD neighbor solicitation, and a `wait-learned` pre + step polls `bridge fdb show dev v0` for it, under `timeout 20`. With a + `dev` filter, `bridge` leaves `dev v0` out of its lines, so the poll + matches the MAC alone. Rows: show (static, VLAN and learned + entries), add static, add in a VLAN, add existing, replace, del, del + missing, and an `ip monitor all` event row. +- **VLAN** (10 rows): show (RTM_GETVLAN's dump, plus the old `RTM_GETLINK` + `RTEXT_FILTER_BRVLAN` dump, its compressed form, and `vlan global`), add, + add a range, add the PVID untagged, add on the bridge itself (`self`), add + the reserved VID 4095, del, del a range, `set ... state`, and an event + row. `ip monitor` never joins `RTNLGRP_BRVLAN`, so the event row runs + `bridge monitor vlan` under the new `xtcp2-nl-brmon` helper + (`nix/microvms/netlink-capture.nix`), whose canary is a VLAN it adds + until the monitor prints it. +- **MPLS** (18 rows): `platform_labels 1000` and `d0` with + `net.mpls.conf.d0.input 1`. `AF_MPLS` swap (`100 as 200/300`), pop, + multipath, a `via inet6`; refusals for an existing label, the reserved + label 3 and label 2000 (past the table); show, get, del, del missing + (accepted), del past the table. Then `encap mpls` on IPv4 and IPv6 routes, + with `ttl`, inside `RTA_MULTIPATH`, a show of all three, and an + `ip monitor route` event row. + +| description | input | expected | +|---|---|---| +| positive: the rows are the design's, in order | `brmplscorpus::rows` | the 36 slugs, written out again as literals | +| negative: every row has six fields and a unique slug | the rows | no malformed or duplicate slug | +| positive: exactly the refusal rows expect a non-zero status | the rows | 3 `bridge` rows rc 255; 4 `ip` rows rc 2 | +| corner: a refused `bridge` write exits 255, not 2 | `tool_mismatch` | none (`bridge`'s `do_cmd` returns -1 unnegated) | +| corner: deleting a missing label in range is accepted | `mpls_route_del_missing` | rc 0 | +| negative: no floor below the write's own requests | every row | ≥2, ≥4 when the command names `dev` | +| negative: nothing holds a single quote | commands, pre steps and readbacks | none | +| positive: a `bridge` pre step runs `bridge -n ` | `{bridge vlan add vid 10 dev v0}` | one call, rc 1 | +| negative: a refused `bridge` pre step is a refusal | the recorder refuses `^bridge` | 0, and it is in `nltopo::refused` | +| boundary: a `wait-learned` with the wrong arity | 1 or 3 arguments | an error, nothing sent | +| positive: MPLS sysctls in order | `PRE_MPLS` | `platform_labels` first; `conf.d0.input` after `d0` exists | +| corner: `ip monitor all` prefixes a group banner | `FDB_UNTIL` | no ERE anchored with `^` | +| positive: `rc_problem` names `brmpls/` | `x 0 2` | `brmpls/x/ip.rc is 2, want 0` | + +`xtcp2-nl-brmon`'s rows are in `addr-topology-test.exp`, against a stand-in +`bridge` (`NLCAP_BRIDGE`): + +| description | input | expected | +|---|---|---| +| positive: canary seen, trigger run, line matched | `v0 "monitor vlan" "" "^v0 +10( \|$)"` | rc 0, `v0 10` printed | +| negative: three arguments | no ERE | rc 2, usage | +| negative: an empty canary device | `""` | rc 2, usage | +| corner: a shell metacharacter in the canary device | `v0;x` | rc 2, usage | +| negative: the monitor never prints the canary | a silent `bridge` | rc 1, `never printed the canary` | +| negative: the trigger fails | `false` | rc 1, the monitor's output still printed | +| boundary: an ERE that never matches | `^v0 +11` | rc 1, `no match for ^v0 +11` | +| corner: every ERE must match, not just the first | `^v0 +10`, `^v0 +12` | rc 1, naming the second | + +**B2, parsers** (`pkg/xtcpnl`), each with a table test whose positive rows +come from the B1 pcaps and whose negative, boundary and corner rows are +hand-built, and a `parseBenchCases` row: + +- `ParseNeigh` gains `NDA_MASTER` and `NDA_VLAN`, with presence bits, as + `nl_parse_fdb` reads them. +- `ParseBrVlan` (new, `xtcpnl_brvlan.go`): the `br_vlan_msg` header and + `BRIDGE_VLANDB_ENTRY` (`_INFO` flags and VID, `_RANGE`, `_STATE`) and + `BRIDGE_VLANDB_GLOBAL_OPTIONS` (`_ID`, `_RANGE`). +- MPLS (new, `xtcpnl_mpls.go`): label stacks (20-bit label, TC, BoS, TTL), + `RTA_NEWDST`, `RTA_ENCAP_TYPE`, and `RTA_ENCAP` with `MPLS_IPTUNNEL_DST` + and `MPLS_IPTUNNEL_TTL`, on a route and inside each `RTA_MULTIPATH` + nexthop. + +| description | input | expected | +|---|---|---| +| positive: a static FDB entry | `fdb_add_static`'s answer | `AF_BRIDGE`, the MAC, `NDA_MASTER` = br0's index, `NUD_NOARP` | +| positive: an FDB entry in a VLAN | `fdb_add_vlan` | `NDA_VLAN` 10 | +| positive: a learned FDB entry | `fdb_show` | the learned MAC, `NUD_REACHABLE`, VLAN 1 | +| boundary: `NDA_VLAN` of one byte | hand-built | `HasVlan` false, not an error (a short `NDA_PROBES` is treated the same way) | +| corner: a repeated `NDA_MASTER` | hand-built | the first wins | +| positive: one VLAN | `vlan_add`'s `RTM_NEWVLAN` | VID 10, flags 0 | +| positive: a range | `vlan_add_range` | VID 20, range 30, untagged | +| positive: PVID untagged | `vlan_add_pvid` | PVID and untagged flags | +| positive: the port STP state | `vlan_set_state` | state `BR_STATE_BLOCKING` | +| boundary: a `br_vlan_msg` shorter than its 8 bytes | hand-built | an error | +| negative: `_INFO` shorter than `bridge_vlan_info` | hand-built | an error | +| positive: a two-label swap | `mpls_route_add_swap` | dst 100; `RTA_NEWDST` 200, 300 with BoS on the last | +| positive: a multipath `AF_MPLS` route | `mpls_route_add_multipath` | two nexthops, labels 201 and 202 | +| positive: a two-label push | `route_encap_mpls` | encap type MPLS; labels 100, 200 | +| positive: the encap TTL | `route_encap_mpls_ttl` | TTL 64 | +| positive: a per-nexthop encap | `route_multipath_encap_mpls` | 101 on the first nexthop, 102 on the second | +| boundary: a label stack not a multiple of 4 bytes | hand-built | an error | +| corner: a stack without BoS on its last label | hand-built | an error | +| corner: an `RTA_ENCAP` of an unknown encap type | hand-built | kept raw, not an error | + +**B3, completeness.** `TestBrMplsCorpusComplete`: every row of B1 has its +`ip.pcap`, `ip.rc` and readback pcaps under +`testdata//link-write/brmpls/`, and every positive B2 row names a +pcap that exists. + +| description | input | expected | +|---|---|---| +| positive: every row has its files | the 36 slugs | `ip.pcap`, `ip.rc`, each readback | +| negative: a row whose capture is missing | a slug with no directory | the test names it | +| corner: the rows' statuses are the corpus's | each `ip.rc` | 0, 2 or 255 as in B1 | + +#### Delivered: B1–B3 (2026-10-31) + +- **Captures.** `pkg/xtcpnl/testdata/7_3_0/link-write/brmpls/`, 36 rows, from + the brmpls section alone. The full link-write run took hours on a loaded + host and timed out twice before it reached these rows, last of all. So + the runner now takes `--only ` + (`nix/microvms/scripts/link-write-sections.exp`: lacp, addr, write, rtnl, + genl, xfrm, kinds, brmpls), and each section has its own app, + `microvm-x86_64-link-write-capture-
`. A section run writes the + same bytes as a full run would: every row rebuilds its namespace, and + late setup runs whenever a section after it is picked. + `link-write-sections-test.exp` (34 rows, in `checks.vm-lib-exp`) pins the + names, their order, and the driver refusing a bad list before it boots + anything. +- **The first section run found a dead poll.** `wait-learned` grepped for + `^ dev v0 `, a form `bridge fdb show dev v0` never prints, and its + loop could outlast `pre_step`'s 30 s and still be running when the next + row's command was typed. The pattern is now `learned_re`, tested against + the line a real `bridge` printed, and the loop is under `timeout 20`. +- **Parsers.** `ParseNeigh`'s `NDA_MASTER` and `NDA_VLAN`, `ParseBrVlan`, + and the MPLS label, `RTA_NEWDST` and `RTA_ENCAP` decoding, as planned, with + hand-built tables (`TestParseNeigh`, `TestParseBrVlan`, + `xtcpnl_mpls_test.go`) and three `parseBenchCases` rows run against the + brmpls corpus. +- **Corpus tests** (`xtcpnl_brmpls_corpus_test.go`): + - `TestBrMplsCorpusComplete`: B3's rows, plus a corner row that the + corpus has no directory the table does not name. + - `TestBrMplsCorpusWrites`: each row's write, decoded with the parsers + under test, and the kernel's answer (26 rows). + - `TestBrMplsCorpusReplies`: the dumps, readbacks and event rows' + notifications, decoded in the kernel's order (17 rows). +- **What the bytes showed, against the plan's table:** + - `bridge vlan add` and `del` still use the old API: an `RTM_SETLINK` or + `RTM_DELLINK` with `IFLA_AF_SPEC`. Only `bridge vlan set` sends + `RTM_NEWVLAN`, with `BRIDGE_VLAN_INFO_ONLY_OPTS` (64). So B2's + `RTM_NEWVLAN` rows are checked against each row's `getvlan` readback, + and the VLAN event row's notifications, not its write. + - `bridge vlan show dev v0` dumps every port and filters in userspace + (`bridge/vlan.c`, `filter_index`), so every readback holds br0's entry + too. + - Deleting a missing label in range is acked; label 3 is refused by + `nla_get_labels`'s implicit-null check, not by the reserved-range check. + - `RTM_GETVLAN` was one of `TestNetlinkUniverse`'s exclusions ("bridge + only"). The test flagged the exclusion as stale once the corpus held + it, and it is removed: five exclusions remain. + +#### Still missing for BIRD: B4, each namespace's links and addresses (2026-10-31) + +BIRD's tests now use the brmpls MPLS rows: `mpls_route_show`, +`mpls_route_add_via6`'s readback, `route_encap_show` (both families) and +`mpls_event`. Every captured route parses to what `ip` printed. The bridge +rows can't be used yet. **No brmpls row records the links or the addresses +of its namespace**, and BIRD needs both before it accepts anything else +from that namespace: + +- **Links.** Every `RTM_NEWLINK` in a brmpls `ip.pcap` or readback is a reply + to the name lookup `ip`/`bridge` makes before printing. It is 40 bytes, and + `IFLA_IFNAME` is its only attribute. BIRD's `nl_parse_link` needs + `IFLA_MTU` and rejects these ("KIF: Malformed message received"), so the + interface stays unknown. Every route on it is then dropped ("unknown + ifindex"). The one exception is `fdb_event`, whose `ip monitor all` saw full + br0 and v0 messages, but in a namespace with the canary interface. +- **Addresses.** BIRD keeps a route only if its gateway is on a connected + subnet of the interface (`neigh_find`). No brmpls row has an + `RTM_NEWADDR` dump. 192.0.2.1/24 on d0 is visible only in `topology`. +- **The bridge itself.** `nl_parse_vlan` takes a VLAN only from a bridge it + knows has VLAN filtering on. It learns that from br0's full link message: + `IFLA_LINKINFO` kind `bridge`, `IFLA_INFO_DATA` → `IFLA_BR_VLAN_FILTERING`. + `nl_parse_fdb` likewise needs br0's index and v0 as a known port. + +Correction to the **bridge VLANs** row above: BIRD's `nl_parse_vlan` reads +the old form, `RTM_NEWLINK` with `ifi_family = AF_BRIDGE` and +`IFLA_AF_SPEC` → `IFLA_BRIDGE_VLAN_INFO`, not `RTM_NEWVLAN`. Its own writes +are `RTM_SETLINK`/`RTM_DELLINK`, the same as `bridge vlan add`/`del`. So +`vlan_show`'s `netlink_route_getlink_brvlan.pcap` is the right reply. Its two +`AF_BRIDGE` messages, for br0 and v0, are full. What it lacks is the +`AF_UNSPEC` links next to them, which are name lookups again. + +**Workaround in BIRD until then.** The MPLS tests take goip0's full link and +address messages from `7_3_0/dumps/` and rename them to d0 with the index +changed: 3, or 4 in `mpls_event`, where the canary `c0` takes 3. This works +only because the brmpls `topology` gives d0 goip0's addresses. There is no +captured bridge link to start from, so the FDB and VLAN tests wait for B4. + +**B4, the fix.** Add two readbacks to the brmpls rows BIRD reads, taken +after the row's command, with the same `{slug floor {command}}` shape as +`ROUTE_RB`: + +``` +variable LINK_RB {{getlink 2 {ip -d link show}}} +variable ADDR_RB {{getaddr 2 {ip addr show}}} +``` + +- `ip -d link show` and `ip addr show` with no `dev` filter, so the reply is + a full `NLM_F_DUMP` (every attribute, `IFLA_LINKINFO` included) rather + than a name lookup. +- Rows: `fdb_show`, `fdb_event`, `vlan_show`, `vlan_event`, + `mpls_route_show`, `mpls_route_add_via6`, `route_encap_show` and + `mpls_event`, each appending `$LINK_RB $ADDR_RB` to its readbacks. For + the event rows, read back after the trigger: their links stay up, and + BIRD loads the links before replaying the events. +- Optional: drop the canary from `mpls_event`, or keep it and record in the + row that d0 is index 4. Either way, the readback makes the index explicit. + +B4's tests extend `TestBrMplsCorpusComplete`: + +| description | input | expected | +|---|---|---| +| positive: every BIRD row has a link and an address readback | the 8 slugs above | `netlink_route_getlink.pcap` and `netlink_route_getaddr.pcap` in each | +| positive: a link readback is a full dump | each `getlink` pcap | every `RTM_NEWLINK` has `IFLA_MTU` and `IFLA_LINKINFO` (lo has no `IFLA_LINKINFO`, so skip it) | +| negative: a name-lookup reply is not a link readback | a 40-byte `RTM_NEWLINK` holding only `IFLA_IFNAME` (today's `ip.pcap`) | the test names the row | +| positive: br0's link says VLAN filtering is on | `vlan_show`'s `getlink` | kind `bridge`, `IFLA_BR_VLAN_FILTERING` = 1 | +| positive: d0 has the addresses of `topology` | each MPLS row's `getaddr` | 192.0.2.1/24 and 2001:db8::1/64 on d0 | +| corner: the event rows' indexes | `mpls_event`'s `getlink` | c0 at 3, d0 at 4, as the routes' `RTA_OIF` say | +| boundary: an address dump of a namespace without addresses | a row whose `topology` adds none | an empty dump (`NLMSG_DONE` only), not a missing file | + +Once these land, BIRD's FDB and VLAN tests use them directly. The MPLS tests +then drop the renamed goip0 messages and load d0 from its own namespace. + +#### Delivered: B4 (2026-10-31) + +- **Captures.** `brmpls-write-corpus.exp` has `LINK_RB` and `ADDR_RB` as + above, appended (as `NS_RB`) to the eight rows' readbacks, after each + row's own. `nix run .#microvm-x86_64-link-write-capture-brmpls` rewrote the + whole `link-write/brmpls/` directory from one run. c0's and v0's MACs are + random on every run, br0 takes v0's, and the FDB rows' events carry them. + So a row's readbacks must come from the same run as its `ip.pcap`. + `brmpls-write-corpus-test.exp` pins which rows read back links and + addresses (those eight, and no others) and that neither dump has a `dev` + filter. +- **Tests** (`xtcpnl_brmpls_corpus_test.go`): `TestBrMplsCorpusComplete` + expects both pcaps in the eight rows. `TestBrMplsCorpusLinkReadbacks`, + `…FullLinkProblem`, `…BridgeForBird`, `…AddrReadbacks` and + `…EventIndexes` hold the table's rows, with three changes the bytes forced. + `fullLinkProblem` is `nl_parse_link`'s test (`sysdep/linux/netlink.c:1088` + in BIRD): `IFLA_MTU` on every link, and `IFLA_LINKINFO` on every link but + lo. Three mutants of it are each caught by a row. +- **What the bytes showed, against the table above:** + - Index 2 in every namespace is `nlmon0`, the capture device, so the + indexes are lo 1, nlmon0 2, then the row's devices. In `mpls_event`, c0 + is 3 and d0 is 4, as the routes' `RTA_OIF` say; in the other MPLS rows + d0 is 3. The canary stays. + - lo is up in every row's namespace, with 127.0.0.1/8 and ::1/128. No + address dump is empty, so the boundary row is now "a namespace whose + devices have no address dumps only lo's" (`vlan_show`, `vlan_event`). + - `bridge`'s name lookups are not single lookups: `bridge` dumps every + link, names only (24-byte bodies), lo first. The negative row names lo. + - In the old-API VLAN dump BIRD reads, br0's own `AF_BRIDGE` entry + carries `IFLA_MASTER` = br0. `nl_parse_vlan` requires `IFLA_MASTER`, but + br0's entry has it, so BIRD takes br0's VLANs as well as v0's. + - BIRD dumps addresses per family (`AF_INET`, then `AF_INET6`); `ip addr + show` makes one `AF_UNSPEC` dump, which holds the same messages. Its + pcap also has the link dump `ip` makes first. + ## What has landed The first three entries are from `feat/netlink-events-and-coverage-roadmap` diff --git a/docs/netlink/goip-link-kinds-status.md b/docs/netlink/goip-link-kinds-status.md new file mode 100644 index 000000000..6c93bf1a1 --- /dev/null +++ b/docs/netlink/goip-link-kinds-status.md @@ -0,0 +1,617 @@ +# goip ↔ `ip` link kinds: implementation status + +**Last updated:** 2026-11-02 + +**Summary:** +- The design and the task plan are in [goip-link-kinds.md](goip-link-kinds.md). This document tracks progress against them. +- The plan is written (DOC001). +- KC0 is done (KC0-T01): the capture drivers load the kind modules late, after every existing row, with no fallback tunnel devices in the namespaces made afterwards, and a `kinds` sidecar records what the guest kernel creates. It creates 31 of the 32 probed kinds. gtp is refused, and KC4 decides why. +- KC1 is done (KC1-T01): 18 `ip link set dev d0 inet …` rows, 11 the kernel answers and 7 `ip` refuses before netlink, are in the rtnl corpus. They are KP0's and KG0's source. +- KC2 is done (KC2-T01, KC2-T02): a kind corpus (`kind-write-corpus.exp`) the link-write driver runs into `link-write/kinds/`, with 60 bridge rows and 42 bridge_slave rows, 80 the kernel accepts, 12 it refuses and 10 `ip` refuses before netlink. They are KP1's and KG1's source. +- KC3 is done (KC3-T01, KC3-T02, KC3-T03): 28 vlan, 34 macvlan, 6 macvtap, 18 ipvlan and 4 ipvtap rows in the kind corpus, stacked on a dummy `d0`; of the 90, 54 the kernel accepts, 14 it refuses and 22 `ip` refuses before netlink. They are KP2's and KG2's source. +- KC4 is done (KC4-T01, KC4-T02, KC4-T03, KC4-T04): KC4-T01 adds 73 vxlan rows, 46 the kernel accepts, 12 it refuses and 15 `ip` refuses before netlink; KC4-T02 adds 49 geneve rows, 22 accepted, 13 refused by the kernel and 14 by `ip`; KC4-T03 adds 16 bareudp and 20 amt rows, 11 accepted, 12 refused by the kernel and 13 by `ip`; KC4-T04 adds 19 gtp rows, 7 accepted, 4 refused by the kernel and 8 by `ip`. They are KP3's and KG3's source. +- KC5 is done (KC5-T01, KC5-T02, KC5-T03, KC5-T04, KC5-T05; planned in DOC006): KC5-T01 adds 45 gre, 11 gretap and 25 erspan rows, 44 the kernel accepts, 16 it refuses and 21 `ip` refuses before netlink; KC5-T02 adds 36 ip6gre, 9 ip6gretap and 19 ip6erspan rows, 36 accepted, 14 refused by the kernel and 14 by `ip`; KC5-T03 adds 47 ipip and 37 sit rows, 46 accepted, 14 refused by the kernel and 24 by `ip` (`6rd-reset` has no row: it segfaults `ip`); KC5-T04 adds 49 ip6tnl rows, 29 accepted, 7 refused by the kernel and 13 by `ip`; KC5-T05 adds 23 vti, 12 vti6 and 23 xfrm rows, 27 accepted, 15 refused by the kernel and 16 by `ip`. They are KP4's and KG4's source. +- KC6 is done (KC6-T01, KC6-T02, KC6-T03, KC6-T04; planned in DOC007): KC6-T01 adds 29 hsr rows, 10 accepted, 10 refused by the kernel and 9 by `ip`; KC6-T02 adds 43 netkit rows, 19 accepted, 8 refused by the kernel and 16 by `ip`; KC6-T03 adds 18 vrf, 10 vrf_slave and 18 vxcan rows, 18 accepted, 10 refused by the kernel and 18 by `ip`; KC6-T04 adds 17 batadv, 9 ifb, 6 nlmon, 13 vcan, 14 team and 4 team_slave rows, 29 accepted, 18 refused by the kernel and 16 by `ip`. They are KP5's and KG5's source. +- KC7 is done (KC7-T01, KC7-T02; planned in DOC008): KC7-T01 commits the 7.3.0 link-write corpus, `testdata/7_3_0/link-write`, 10558 files from one run of the runner at the 7.2.0 pin, every kind row with its status, request and readbacks. KC7-T02 is the gate, `TestLinkKindUniverse`: of the 46 kinds 7.2.0's `iplink_*.c` and `link_*.c` name, 39 are captured with an accepted row and a `-d -j` readback, and 7 are excluded with a reason. +- The KC tier is done. +- KP0 is done (KP0-T01; planned in DOC009): `pkg/xtcpnl/xtcpnl_inet_devconf.go` encodes the `inet` write's `IFLA_AF_SPEC` byte for byte as `ip` does for the 11 KC1 rows the kernel answered, decodes it back, and decodes the dump's 33-value devconf array. KP1 is planned (DOC010). +- KP1 is done (KP1-T01, KP1-T02; planned in DOC010): `pkg/xtcpnl/xtcpnl_bridge.go` is the bridge's 53-row option table and `xtcpnl_bridge_slave.go` the port's 44-row one. Each has a decoder that renders every accepted row's dump exactly as its `-d -j` readback prints it (50 bridge rows, 30 port rows), and an encoder that rebuilds the data nest of every row that sent a request byte for byte (55 and 37). `LinkInfo` carries `Bridge` and `BridgeSlave`. KP2 is planned (DOC011). +- KP2 is done (KP2-T01, KP2-T02, KP2-T03; planned in DOC011): `pkg/xtcpnl/xtcpnl_vlan.go` decodes the vlan kind into `LinkInfo.Vlan`, rendering all 15 accepted rows' dumps as their `-d -j` readbacks print them, and encodes the `IFLA_INFO_DATA` of the 21 rows that sent a request byte for byte. `xtcpnl_macvlan.go` does the same for macvlan and macvtap into `LinkInfo.Macvlan` (25 accepted rows, 30 requests), and `xtcpnl_ipvlan.go` for ipvlan and ipvtap into `LinkInfo.Ipvlan` (14 and 17). KP3 is planned (DOC012). +- KP3 is done (KP3-T01, KP3-T02, KP3-T03, KP3-T04; planned in DOC012): `pkg/xtcpnl/xtcpnl_vxlan.go` decodes the vxlan kind into `LinkInfo.Vxlan`, rendering all 46 accepted rows' dumps as their `-d -j` readbacks print them, and encodes the `IFLA_INFO_DATA` of the 58 rows that sent a request byte for byte, adds and sets alike. `xtcpnl_geneve.go` does the same for geneve into `LinkInfo.Geneve` (22 accepted rows, 35 requests), `xtcpnl_bareudp.go` for bareudp into `LinkInfo.Bareudp` (6 and 10), `xtcpnl_amt.go` for amt into `LinkInfo.Amt` (5 and 13) and `xtcpnl_gtp.go` for gtp into `LinkInfo.Gtp` (7 and 11); they share `xtcpnl_kind_wire.go`. KP4 is planned (DOC013). +- KP4 is done (KP4-T01, KP4-T02, KP4-T03, KP4-T04, KP4-T05; planned in DOC013): `pkg/xtcpnl/xtcpnl_gre.go` decodes the six gre kinds into `LinkInfo.Gre`, rendering all 44 accepted gre, gretap and erspan rows' dumps as their `-d -j` readbacks print them, and encodes the `IFLA_INFO_DATA` of the 60 rows that sent a request byte for byte, sets seeded from their prefetch. `xtcpnl_gre6.go` does the same for ip6gre, ip6gretap and ip6erspan (36 accepted rows, 50 requests), and `xtcpnl_iptun.go` for ipip and sit into `LinkInfo.Iptun` (46 and 60), which ip6tnl's dump also fills; `xtcpnl_ip6tnl.go` encodes ip6tnl (29 accepted rows, 36 requests). `xtcpnl_vti.go` does the same for vti and vti6 into `LinkInfo.Vti` (18 and 25), and `xtcpnl_xfrm_link.go` for xfrm into `LinkInfo.Xfrm` (9 and 17). The shared `TunnelEncap` block lives in `xtcpnl_kind_wire.go`. KP5 is planned (DOC014). +- KP5 is done (KP5-T01, KP5-T02, KP5-T03, KP5-T04; planned in DOC014): `pkg/xtcpnl/xtcpnl_hsr.go` decodes hsr into `LinkInfo.Hsr`, rendering all 10 accepted rows' dumps as their `-d -j` readbacks print them, and encodes the `IFLA_INFO_DATA` of the 20 rows that sent a request byte for byte. `xtcpnl_netkit.go` does the same for netkit into `LinkInfo.Netkit` (19 accepted rows, 27 requests), its `PEER_INFO` nest shared with `EncodeVethPeer` through `putPeerNest`. `xtcpnl_vrf.go` encodes vrf's 12 requests (the 8 accepted rows' dumps and the 3 enslaved vrf_slave rows' render through the existing `vrfTableAttr`), and `xtcpnl_vxcan.go` vxcan's 9 through `putPeerNest`; no vrf_slave request carries `INFO_SLAVE_DATA`. `xtcpnl_batadv.go` encodes batadv's 12 requests; batadv has no `fill_info`, and ifb, nlmon, vcan, team and team_slave send and dump no kind data at all, which `TestKindsWithoutData` holds for all 46 of their requests. +- The KP tier is done. KG0 is planned (DOC015): KG0-T01 puts `inet` in goip's keyword chain, with `ip`'s exit statuses, and KG0-T02 gates its 11 matrix rows. +- KG0 is done (KG0-T01, KG0-T02; planned in DOC015): goip builds `ip link set … inet …` byte for byte as `ip` does for the 11 KC1 rows the kernel answered, and refuses the 7 `ip` refuses with `ip`'s texts and statuses. `parseLinkArgs`' `exit(-1)` refusals now exit 255 through `ErrLinkUsage`. The 11 `link_inet_*` matrix rows are gated, on a clean full run and a clean `--section link` run (V135, V136), the first rows gated with a section run. KG1 is planned (DOC016). +- KG1 is done (KG1-T01, KG1-T02, KG1-T03, KG1-T04, KG1-T05; planned in DOC016): goip builds `ip link add|set … type bridge …` byte for byte as `ip` does for KC2's 55 bridge rows that sent a request, and `ip link set … type bridge_slave …` for its 37 bridge_slave rows, lookups included, and refuses the 10 `ip` refuses with `ip`'s texts and statuses. `-d link show` renders a bridge as `ip` does for all 50 accepted rows, text and JSON, and a bridge port for all 30 of its accepted rows, with `ip`'s gets. The 92 write rows and 4 `-d` read rows are in the matrix and gated, on two clean `--section link` runs (V154, V155). KG2 is planned (DOC017). +- KG2 is done (KG2-T01, KG2-T02, KG2-T03, KG2-T04, KG2-T05; planned in DOC017): goip takes `iplink_parse`'s `link` keyword, resolving the lower device before `IFLA_IFNAME` on a create, and builds `ip link add|set … type vlan|macvlan|macvtap …` byte for byte as `ip` does for KC3's 21 vlan, 25 macvlan and 5 macvtap rows that sent a request, lookups included, and refuses the 17 `ip` refuses with `ip`'s texts and statuses. KG2-T03 adds `type ipvlan|ipvtap …`: KC3's 14 ipvlan and 3 ipvtap rows that sent a request, and the 5 refusals. KG2-T04 renders `-d link show` of the five kinds as `ip` does for all 54 accepted rows, text and JSON, with `ip`'s gets. The 68 write rows and 10 `-d` read rows are in the matrix and gated, on two clean `--section link` runs (V169, V170). KG3 is planned (DOC018). +- KG3 is done (KG3-T01, KG3-T02, KG3-T03, KG3-T04, KG3-T05; planned in DOC018): goip builds `ip link add|set … type vxlan …` byte for byte as `ip` does for KC4's 58 vxlan rows that sent a request, lookups included, prints `ip`'s dstport warning, refuses the 15 `ip` refuses with `ip`'s texts and statuses, and sends `ip -4`'s and `-6`'s default group. KG3-T02 adds `type geneve …`: KC4's 35 geneve rows that sent a request, and the 14 refusals. KG3-T03 adds `type bareudp|amt|gtp …`: KC4's 10, 13 and 11 rows that sent a request, and the 21 refusals. KG3-T04 renders `-d link show` and `-d -j link show` of the five kinds as `ip`'s print_opts do, for all 86 accepted KC4 rows with `ip`'s gets, and vxlan's `-d -d`. The 127 write rows and 11 `-d` read rows are in the matrix and gated, on two clean `--section link` runs (V186, V187). KG4 is planned (DOC019). +- KG4 is done (KG4-T01 to KG4-T09; planned in DOC019). KG4-T01 has goip-parity set `fb_tunnels_only_for_init_net=2` before its first row, so a `--section link` run makes no fallback tunnels (V194). KG4-T02 adds the set prefetch every tunnel parser but xfrm's shares: `Service.LinkInfoDataByIndex`'s 32-byte `RTM_GETLINK` with no `IFLA_EXT_MASK`, sent without `NLM_F_CREATE` (so on `set`, `change` and `delete`) when a word follows the kind, with `ip`'s `Failed to get existing tunnel info.` checks. On it, goip builds `ip link add|set|delete … type gre|gretap|erspan …` byte for byte as `ip` does for KC5-T01's 31 gre, 10 gretap and 19 erspan rows that sent a request, lookups and prefetch included, and refuses the 21 `ip` refuses with `ip`'s texts and statuses (V195–V197). KG4-T03 adds `type ip6gre|ip6gretap|ip6erspan …` on the same prefetch: KC5-T02's 26, 9 and 15 rows that sent a request, and the 14 `ip` refuses (V198–V200). KG4-T04 adds `type ipip|sit …`: KC5-T03's 34 ipip and 26 sit rows that sent a request, and the 24 `ip` refuses; `6rd-reset`, which crashes `ip` 7.2.0, and a 6rd prefix of `any`, `all` or `default`, which `ip` parses but doesn't send, are `ErrNotImplemented` (V201–V203). KG4-T05 adds `type ip6tnl …`: KC5-T04's 36 rows that sent a request, and the 13 `ip` refuses; an encaplimit that is neither a u8 nor `none`, for which `ip` sends an uninitialized byte, is `ErrNotImplemented` (V204–V206). KG4-T06 adds `type vti|vti6|xfrm …`: KC5-T05's 16 vti, 9 vti6 and 17 xfrm rows that sent a request, and the 16 `ip` refuses; vti and vti6 share the prefetch, and xfrm has none (V207–V209). KG4-T07 renders the six gre kinds' `-d` output, matching every one of KC5-T01's and KC5-T02's 80 accepted rows' `-d` and `-d -j` readbacks; the decoder now keeps each endpoint's length, which `tnl_print_endpoint` reads as the family (V210–V212). KG4-T08 renders ipip's, sit's, ip6tnl's, vti's, vti6's and xfrm's, matching all 102 of KC5-T03's to KC5-T05's accepted rows; with it the 7_1_4 tunnel topology's `-d link show` and `-d addr show` render, matching their sidecars, and the refusal's test moves to hsr and netkit (V213–V215). KG4-T09 puts the 248 write rows and 24 `-d` read rows in the matrix, each write row's floor its capture's record count, the prefetch counted, and gates them on two clean `--section link` runs (V216–V221). KG5 is planned (DOC020). +- KG5 is done (KG5-T01 to KG5-T07; planned in DOC020), and with it the plan. KG5-T01 sends ifb, nlmon, vcan, team, team_slave and vrf_slave through dummy's else arm (`Garbage instead of arguments`, 1; `help` not implemented), and adds `iplink_parse`'s `vrf NAME` (the resolver, then `Service.IsVRF`, `Not a valid VRF name` at 255, `IFLA_MASTER`) and `numtxqueues`/`numrxqueues` (`intAttr`, as `mtu`). goip builds all 42 of the six kinds' rows that sent a request byte for byte, lookups included, and refuses the 12 `ip` refuses with its texts and statuses; the workbench differential's `linkadd` rows go from 43 to 111 the same as `ip` (V223–V225). KG5-T02 adds `type hsr …`: KC6's 20 hsr rows that sent a request, byte for byte with the slaves' gets, and the 9 `ip` refuses; `version` and `proto` send any u8, as `ip`'s check does (V226–V228). KG5-T03 adds `type netkit …`: KC6's 27 netkit rows that sent a request, the peer's `PEER_INFO` parsed as veth's is, and the 15 `ip` refuses, `duparg`'s `(null)` included; `help` in the peer is `ErrNotImplemented` (V229–V231). KG5-T04 adds `type vrf|vxcan|batadv …`: KC6's 12, 9 and 12 rows that sent a request and the 19 `ip` refuses; vxcan is `vethData` with its own usage and `VXCAN_INFO_PEER`, and veth's peer `type` now exits 255 as `duparg` does, where it exited 1 (V232–V234). KG5-T05 moves the bond parsers' `invarg`s and incomplete line to 255, as `ip`'s are, keeps the rest at 1, and gives the address errors `ip`'s `Error: ` prefix; `link_bond_args.go` and `link_write.go` lose their six `//nolint:gosec` for bounded conversions (`s32Bits`, `u32AsS32`, `cIntBits`, `low32`), and an ifindex `ip` tests only for 0 goes out as its 32 bits (V235–V237). KG5-T06 renders hsr's and netkit's `-d` output, matching all 29 of KC6's accepted hsr and netkit rows' `-d` and `-d -j` readbacks, the slaves' gets included; every kind KC captured now renders, so the refusal's test moves to built links (V238–V240). KG5-T07 adds the eleven kinds' 122 write rows and 22 `-d` read rows to goip-parity's table, now 1246 rows, and gates all 144 after two clean `--section link` runs of 776 rows each (V241–V245). + +**Next action:** none in this plan; KC0 to KG5 are done. The full run before the next push is clean at 3a8bc8e3 (V246). The next goip parity work, the write keywords the workbench differential shows goip refusing, is planned in [goip-write-keywords.md](goip-write-keywords.md) and tracked in [its status doc](goip-write-keywords-status.md). The 474 G115 narrowing conversions are a separate TODO ([static-analysis.md](../static-analysis.md#todo-g115-the-narrowing-conversions-the-exclusion-hides)). + +## Contents + +- [Update rules](#update-rules) +- [Phase states](#phase-states) +- [Task checklist](#task-checklist) +- [Verification ledger](#verification-ledger) +- [Decision log](#decision-log) + +## Update rules + +- States are `not started`, `in progress`, `blocked`, `done` or `deferred`. There are no percentages. +- A task is `done` only when its "Done when" has passed and the evidence is in the [ledger](#verification-ledger). +- Evidence IDs (`V###` for code and VM runs, `DOC###` for docs) are immutable and append-only. +- Task IDs (`KCn-Tmm`, `KPn-Tmm`, `KGn-Tmm`) are never renumbered. +- Every task's commit flips its own checklist row and appends its `V###` rows. +- A phase's last task also closes the phase, following [Closing a phase](goip-link-kinds.md#closing-a-phase). +- A statement elsewhere in `docs/netlink/` that a task makes false is amended in that task's commit. + +## Phase states + +| Phase | Tier | Work | Closed by | State | Evidence | +|---|---|---|---|---|---| +| [KC0](goip-link-kinds.md#kc0--kind-harness) | captures | kind harness | KC0-T01 | done | V001–V005, DOC002 | +| [KC1](goip-link-kinds.md#kc1--inet-devconf-captures) | captures | `inet` devconf | KC1-T01 | done | V006–V008, DOC003 | +| [KC2](goip-link-kinds.md#kc2--bridge-captures) | captures | bridge | KC2-T02 | done | V009–V016, DOC004 | +| [KC3](goip-link-kinds.md#kc3--vlan-family-captures) | captures | VLAN family | KC3-T03 | done | V017–V025, DOC005 | +| [KC4](goip-link-kinds.md#kc4--udp-overlay-captures) | captures | UDP overlays | KC4-T04 | done | V026–V037, DOC006 | +| [KC5](goip-link-kinds.md#kc5--ip-tunnel-captures) | captures | IP tunnels | KC5-T05 | done | V038–V052, DOC007 | +| [KC6](goip-link-kinds.md#kc6--the-remaining-kinds-captures) | captures | the remaining kinds | KC6-T04 | done | V053–V064, DOC008 | +| [KC7](goip-link-kinds.md#kc7--kind-completeness-gate) | captures | completeness gate | KC7-T02 | done | V065–V071, DOC009 | +| [KP0](goip-link-kinds.md#kp0--inet-devconf-codec) | parsers | `inet` devconf codec | KP0-T01 | done | V072–V074, DOC010 | +| [KP1](goip-link-kinds.md#kp1--bridge-parsers) | parsers | bridge | KP1-T02 | done | V075–V080, DOC011 | +| [KP2](goip-link-kinds.md#kp2--vlan-family-parsers) | parsers | VLAN family | KP2-T03 | done | V081–V089, DOC012 | +| [KP3](goip-link-kinds.md#kp3--udp-overlay-parsers) | parsers | UDP overlays | KP3-T04 | done | V090–V101, DOC013 | +| [KP4](goip-link-kinds.md#kp4--ip-tunnel-parsers) | parsers | IP tunnels | KP4-T05 | done | V102–V116, DOC014 | +| [KP5](goip-link-kinds.md#kp5--the-remaining-kinds-parsers) | parsers | the remaining kinds | KP5-T04 | done | V117–V128, DOC015 | +| [KG0](goip-link-kinds.md#kg0--goip-link-set-inet) | goip | `link set inet` | KG0-T02 | done | V129–V139, DOC016 | +| [KG1](goip-link-kinds.md#kg1--goip-bridge) | goip | bridge | KG1-T05 | done | V140–V156, DOC017 | +| [KG2](goip-link-kinds.md#kg2--goip-vlan-family) | goip | VLAN family | KG2-T05 | done | V157–V173, DOC018 | +| [KG3](goip-link-kinds.md#kg3--goip-udp-overlays) | goip | UDP overlays | KG3-T05 | done | V174–V190, DOC019 | +| [KG4](goip-link-kinds.md#kg4--goip-ip-tunnels) | goip | IP tunnels | KG4-T09 | done | V191–V222, DOC020 | +| [KG5](goip-link-kinds.md#kg5--goip-the-remaining-kinds) | goip | the remaining kinds | KG5-T07 | done | V223–V246, DOC021 | + +## Task checklist + +Task IDs link to their sections in the plan. A phase not yet broken into tasks is one range row, and gets one row per task when the phase before it closes. IDs already listed never change. + +| Done | Task | Work | State | Evidence | +|---|---|---|---|---| +| [x] | [KC0-T01](goip-link-kinds.md#kc0-t01-late-kind-modules-no-fallback-tunnels-and-a-kind-probe) | late kind modules, fallback-tunnel sysctl, `kinds` probe | done | V001–V005 | +| [x] | [KC1-T01](goip-link-kinds.md#kc1-t01-inet-devconf-write-rows) | `inet` devconf write rows | done | V006–V008 | +| [x] | [KC2-T01](goip-link-kinds.md#kc2-t01-the-kind-corpus-and-bridge) | the kind corpus, and bridge | done | V009–V013 | +| [x] | [KC2-T02](goip-link-kinds.md#kc2-t02-bridge_slave) | bridge_slave | done | V014–V016 | +| [x] | [KC3-T01](goip-link-kinds.md#kc3-t01-vlan) | vlan | done | V017–V019 | +| [x] | [KC3-T02](goip-link-kinds.md#kc3-t02-macvlan-and-macvtap) | macvlan and macvtap | done | V020–V022 | +| [x] | [KC3-T03](goip-link-kinds.md#kc3-t03-ipvlan-and-ipvtap) | ipvlan and ipvtap | done | V023–V025, DOC005 | +| [x] | [KC4-T01](goip-link-kinds.md#kc4-t01-vxlan) | vxlan | done | V026–V028 | +| [x] | [KC4-T02](goip-link-kinds.md#kc4-t02-geneve) | geneve | done | V029–V031 | +| [x] | [KC4-T03](goip-link-kinds.md#kc4-t03-bareudp-and-amt) | bareudp and amt | done | V032–V034 | +| [x] | [KC4-T04](goip-link-kinds.md#kc4-t04-gtp) | gtp | done | V035–V037, DOC006 | +| [x] | [KC5-T01](goip-link-kinds.md#kc5-t01-gre-gretap-and-erspan) | gre, gretap and erspan | done | V038–V040 | +| [x] | [KC5-T02](goip-link-kinds.md#kc5-t02-ip6gre-ip6gretap-and-ip6erspan) | ip6gre, ip6gretap and ip6erspan | done | V041–V043 | +| [x] | [KC5-T03](goip-link-kinds.md#kc5-t03-ipip-and-sit) | ipip and sit | done | V044–V046 | +| [x] | [KC5-T04](goip-link-kinds.md#kc5-t04-ip6tnl) | ip6tnl | done | V047–V049 | +| [x] | [KC5-T05](goip-link-kinds.md#kc5-t05-vti-vti6-and-xfrm) | vti, vti6 and xfrm | done | V050–V052, DOC007 | +| [x] | [KC6-T01](goip-link-kinds.md#kc6-t01-hsr) | hsr | done | V053–V055 | +| [x] | [KC6-T02](goip-link-kinds.md#kc6-t02-netkit) | netkit | done | V056–V058 | +| [x] | [KC6-T03](goip-link-kinds.md#kc6-t03-vrf-vrf_slave-and-vxcan) | vrf, vrf_slave and vxcan | done | V059–V061 | +| [x] | [KC6-T04](goip-link-kinds.md#kc6-t04-batadv-ifb-nlmon-vcan-team-and-team_slave) | batadv, ifb, nlmon, vcan, team and team_slave | done | V062–V064, DOC008 | +| [x] | [KC7-T01](goip-link-kinds.md#kc7-t01-the-730-link-write-corpus) | the 7.3.0 link-write corpus | done | V065–V068 | +| [x] | [KC7-T02](goip-link-kinds.md#kc7-t02-the-gate) | the completeness gate | done | V069–V071, DOC009 | +| [x] | [KP0-T01](goip-link-kinds.md#kp0-t01-the-inet-devconf-codec) | the `inet` devconf codec | done | V072–V074, DOC010 | +| [x] | [KP1-T01](goip-link-kinds.md#kp1-t01-the-bridge-parser) | the bridge parser | done | V075–V077 | +| [x] | [KP1-T02](goip-link-kinds.md#kp1-t02-the-bridge_slave-parser) | the bridge_slave parser | done | V078–V080, DOC011 | +| [x] | [KP2-T01](goip-link-kinds.md#kp2-t01-the-vlan-parser) | the vlan parser | done | V081–V083 | +| [x] | [KP2-T02](goip-link-kinds.md#kp2-t02-the-macvlan-and-macvtap-parser) | the macvlan and macvtap parser | done | V084–V086 | +| [x] | [KP2-T03](goip-link-kinds.md#kp2-t03-the-ipvlan-and-ipvtap-parser) | the ipvlan and ipvtap parser | done | V087–V089, DOC012 | +| [x] | [KP3-T01](goip-link-kinds.md#kp3-t01-the-vxlan-parser) | the vxlan parser | done | V090–V092 | +| [x] | [KP3-T02](goip-link-kinds.md#kp3-t02-the-geneve-parser) | the geneve parser | done | V093–V095 | +| [x] | [KP3-T03](goip-link-kinds.md#kp3-t03-the-bareudp-and-amt-parsers) | the bareudp and amt parsers | done | V096–V098 | +| [x] | [KP3-T04](goip-link-kinds.md#kp3-t04-the-gtp-parser) | the gtp parser | done | V099–V101, DOC013 | +| [x] | [KP4-T01](goip-link-kinds.md#kp4-t01-the-gre-gretap-and-erspan-parser) | the gre, gretap and erspan parser | done | V102–V104 | +| [x] | [KP4-T02](goip-link-kinds.md#kp4-t02-the-ip6gre-ip6gretap-and-ip6erspan-parser) | the ip6gre, ip6gretap and ip6erspan parser | done | V105–V107 | +| [x] | [KP4-T03](goip-link-kinds.md#kp4-t03-the-ipip-and-sit-parser) | the ipip and sit parser | done | V108–V110 | +| [x] | [KP4-T04](goip-link-kinds.md#kp4-t04-the-ip6tnl-parser) | the ip6tnl parser | done | V111–V113 | +| [x] | [KP4-T05](goip-link-kinds.md#kp4-t05-the-vti-vti6-and-xfrm-parsers) | the vti, vti6 and xfrm parsers | done | V114–V116, DOC014 | +| [x] | [KP5-T01](goip-link-kinds.md#kp5-t01-the-hsr-parser) | the hsr parser | done | V117–V119 | +| [x] | [KP5-T02](goip-link-kinds.md#kp5-t02-the-netkit-parser) | the netkit parser | done | V120–V122 | +| [x] | [KP5-T03](goip-link-kinds.md#kp5-t03-the-vrf-vrf_slave-and-vxcan-parsers) | the vrf, vrf_slave and vxcan parsers | done | V123–V125 | +| [x] | [KP5-T04](goip-link-kinds.md#kp5-t04-the-batadv-parser-and-the-kinds-with-no-parser) | the batadv parser and the kinds with no parser | done | V126–V128, DOC015 | +| [x] | [KG0-T01](goip-link-kinds.md#kg0-t01-inet-in-goips-keyword-chain) | `inet` in goip's keyword chain | done | V129–V131 | +| [x] | [KG0-T02](goip-link-kinds.md#kg0-t02-the-inet-matrix-rows) | the `inet` matrix rows | done | V135–V139 | +| [x] | [KG1-T01](goip-link-kinds.md#kg1-t01-the-bridge-words) | the bridge words | done | V140–V142 | +| [x] | [KG1-T02](goip-link-kinds.md#kg1-t02-the-bridge_slave-words) | the bridge_slave words | done | V143–V145 | +| [x] | [KG1-T03](goip-link-kinds.md#kg1-t03-the-bridges--d-output) | the bridge's `-d` output | done | V146–V148 | +| [x] | [KG1-T04](goip-link-kinds.md#kg1-t04-the-bridge-ports--d-output) | the bridge port's `-d` output | done | V149–V151 | +| [x] | [KG1-T05](goip-link-kinds.md#kg1-t05-the-bridge-matrix-rows) | the bridge matrix rows | done | V152–V156 | +| [x] | [KG2-T01](goip-link-kinds.md#kg2-t01-the-link-keyword-and-the-vlan-words) | the `link` keyword and the vlan words | done | V157–V159 | +| [x] | [KG2-T02](goip-link-kinds.md#kg2-t02-the-macvlan-and-macvtap-words) | the macvlan and macvtap words | done | V160–V162 | +| [x] | [KG2-T03](goip-link-kinds.md#kg2-t03-the-ipvlan-and-ipvtap-words) | the ipvlan and ipvtap words | done | V163–V165 | +| [x] | [KG2-T04](goip-link-kinds.md#kg2-t04-the-vlan-familys--d-output) | the VLAN family's `-d` output | done | V166–V168 | +| [x] | [KG2-T05](goip-link-kinds.md#kg2-t05-the-vlan-family-matrix-rows) | the VLAN family matrix rows | done | V169–V173 | +| [x] | [KG3-T01](goip-link-kinds.md#kg3-t01-the-vxlan-words) | the vxlan words | done | V174–V176 | +| [x] | [KG3-T02](goip-link-kinds.md#kg3-t02-the-geneve-words) | the geneve words | done | V177–V179 | +| [x] | [KG3-T03](goip-link-kinds.md#kg3-t03-the-bareudp-amt-and-gtp-words) | the bareudp, amt and gtp words | done | V180–V182 | +| [x] | [KG3-T04](goip-link-kinds.md#kg3-t04-the-udp-overlays--d-output) | the UDP overlays' `-d` output | done | V183–V185 | +| [x] | [KG3-T05](goip-link-kinds.md#kg3-t05-the-udp-overlay-matrix-rows) | the UDP overlay matrix rows | done | V186–V190 | +| [x] | [KG4-T01](goip-link-kinds.md#kg4-t01-no-fallback-tunnels-in-goip-parity) | no fallback tunnels in goip-parity | done | V191–V194 | +| [x] | [KG4-T02](goip-link-kinds.md#kg4-t02-the-prefetch-and-the-gre-gretap-and-erspan-words) | the prefetch, and the gre, gretap and erspan words | done | V195–V197 | +| [x] | [KG4-T03](goip-link-kinds.md#kg4-t03-the-ip6gre-ip6gretap-and-ip6erspan-words) | the ip6gre, ip6gretap and ip6erspan words | done | V198–V200 | +| [x] | [KG4-T04](goip-link-kinds.md#kg4-t04-the-ipip-and-sit-words) | the ipip and sit words | done | V201–V203 | +| [x] | [KG4-T05](goip-link-kinds.md#kg4-t05-the-ip6tnl-words) | the ip6tnl words | done | V204–V206 | +| [x] | [KG4-T06](goip-link-kinds.md#kg4-t06-the-vti-vti6-and-xfrm-words) | the vti, vti6 and xfrm words | done | V207–V209 | +| [x] | [KG4-T07](goip-link-kinds.md#kg4-t07-the-gre-kinds--d-output) | the gre kinds' `-d` output | done | V210–V212 | +| [x] | [KG4-T08](goip-link-kinds.md#kg4-t08-the-other-tunnels--d-output) | the other tunnels' `-d` output | done | V213–V215 | +| [x] | [KG4-T09](goip-link-kinds.md#kg4-t09-the-ip-tunnel-matrix-rows) | the IP tunnel matrix rows | done | V216–V221 | +| [x] | [KG5-T01](goip-link-kinds.md#kg5-t01-the-kinds-with-no-parser-and-the-vrf-and-queue-keywords) | the kinds with no parser, and the vrf and queue keywords | done | V223–V225 | +| [x] | [KG5-T02](goip-link-kinds.md#kg5-t02-the-hsr-words) | the hsr words | done | V226–V228 | +| [x] | [KG5-T03](goip-link-kinds.md#kg5-t03-the-netkit-words) | the netkit words | done | V229–V231 | +| [x] | [KG5-T04](goip-link-kinds.md#kg5-t04-the-vrf-vxcan-and-batadv-words) | the vrf, vxcan and batadv words | done | V232–V234 | +| [x] | [KG5-T05](goip-link-kinds.md#kg5-t05-the-bond-parsers-statuses) | the bond parsers' statuses | done | V235–V237 | +| [x] | [KG5-T06](goip-link-kinds.md#kg5-t06-the-hsr-and-netkit--d-output) | the hsr and netkit `-d` output | done | V238–V240 | +| [x] | [KG5-T07](goip-link-kinds.md#kg5-t07-the-remaining-kinds-matrix-rows) | the remaining kinds' matrix rows | done | V241–V245 | + +## Verification ledger + +| Evidence ID | Date | Tasks | Command / env | Result | Location | +|---|---|---|---|---|---| +| DOC001 | 2026-10-24 | (the plan) | the inventory read off iproute2 `v7.2.0` (`ip/iplink_*.c`, `ip/link_*.c`) and the kernel's uapi enums; `b8cdda55` checked as after the tag; doccheck (task IDs one to one, phases matched, links and anchors) | 39 kind files: 34 kinds in scope across K1 to K5, plus the `inet` write; bond, bond_slave, dummy and veth already done; 7 kinds and 2 non-kind files out; no broken link or anchor | this commit | +| V001 | 2026-10-24 | KC0-T01 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `addr-topology.exp`: 204 passed, 0 failed, the new `kinds` ccheck rows and `late_setup` rows included; the other suites unchanged | this commit | +| V002 | 2026-10-24 | KC0-T01 | mutation, scratch worktree at the task's WIP, through `vm-lib-exp`: the sysctl dropped, the sysctl after `modules`, a refused kind fatal, the sidecar reversed | 4 of 4 killed: the first two by the sysctl-before-modules and refused-sysctl rows, the third by the boundary and corner `kinds` rows, the last by the argument-order row | scratch | +| V003 | 2026-10-24 | KC0-T01 | `nix build .#checks.x86_64-linux.{nix-fmt,statix,deadnix,lint-baseline}`, one per call | all pass | this commit | +| V004 | 2026-10-24 | KC0-T01 | `nix run .#microvm-x86_64-link-write-capture`, guest kernel 7.3.0, `ip` 7.2.0; then the same driver at `34724ad7` (before this task), and the two compared with `pcapcmp` (record count, `(type, len)` sequence, masked bytes) | `NLCAP_DRIVER_FAIL` in both runs, on one row each: `genl/mptcp_limits_set_over` rc 0, want 2 (see the decision log). `late/kinds`: 31 kinds `ok`, gtp `refused RTNETLINK answers: Invalid argument`. `late/topology`: the sysctl `ok`, `no fallback devices in a new namespace`. Against the baseline: 701 pcaps the same shape, 13 differing: 7 reordered (`tcp_metrics` and neighbor dumps, which follow kernel hash order) and 6 monitor rows, whose datagram counts follow the canary's timing; none missing or new | scratch | +| V005 | 2026-10-24 | KC0-T01 | `nix run .#microvm-x86_64-netlink-dump-capture -- --timeout 7200`, compared with the committed `testdata/7_3_0/dumps` (`ae7b8bcb`, the same kernel and `ip`) | `NLCAP_DRIVER_PASS`. `late/kinds` and `late/topology` as in V004. 115 pcaps the same shape, 5 neighbor dumps reordered, 12 `reads/` pcaps with no 7.3.0 counterpart committed; the reads namespace still holds `tunl0`, `gre0`, `gretap0`, `erspan0`, `sit0`, `ip6tnl0` and `ip6gre0` | scratch | +| DOC002 | 2026-10-24 | KC0 close | KC1 broken into KC1-T01 with its tables; doccheck (task IDs one to one, links and anchors) | no broken link or anchor | this commit | +| V006 | 2026-10-24 | KC1-T01 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `rtnl-write-corpus.exp`: 101 passed, 0 failed (the 18 rows, the 0/1/2/255 statuses, the `ip`-refused floors); the other suites unchanged | this commit | +| V007 | 2026-10-24 | KC1-T01 | mutation, scratch worktree, through `vm-lib-exp`: `bad_rc` narrowed to `{0 2}`, the `ip`-refused floor exemption dropped, `inet_rp_filter_over` at rc 0, two `inet` rows swapped | 4 of 4 killed, by `rc_values`; `floors` and `floor_255_zero`; `refused_statuses`; `slug_list` and `kc1t01_rows_last` | scratch | +| V008 | 2026-10-24 | KC1-T01 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `IFLA_INET_CONF` decoded from each row's `getlink_detail` pcap; the run compared with V004's by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 18 rows hold their statuses: 11 at 0 or 2 with 4 datagrams; `inet_bad_on_off` at 1 and the other 6 at 255, with no datagram. `inet_every_param` sets all 32 ids (every id but `MC_FORWARDING`); `medium_id -1` reads back -1, `tag 0x10` 16, `arp_ignore 010` 8; `inet_repeated` reads `forwarding off`; `inet_rp_filter_over` leaves rp_filter at the namespace's 2. Against V004: 40 new pcaps (the 18 rows'), 700 the same shape, 14 differing in V004's classes (`tcp_metrics` and neighbor order, the monitor and event rows' counts) | scratch | +| DOC003 | 2026-10-24 | KC1 close | KC2 broken into KC2-T01 and KC2-T02 with their tables; doccheck | no broken link or anchor | this commit | +| V009 | 2026-10-24 | KC2-T01 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 34 passed, 0 failed (the 60 rows in order, one `add_` row per value-taking option, the 0/1/2/255 statuses, the floors and readbacks, the bridge states); the other suites unchanged | this commit | +| V010 | 2026-10-24 | KC2-T01 | mutation, scratch worktree, through `vm-lib-exp`: `bad_rc` narrowed to `{0 2}`, `row`'s refused floor made 2, `mcast_hash_max` dropped from the option list, two bridge rows swapped | 4 of 4 killed, by `rc_values`; `floors` and `floor_refused`; `option_rows`, `row_count` and `slug_list`; `slug_list` | scratch | +| V011 | 2026-10-24 | KC2-T01 | `nix build .#checks.x86_64-linux.{nix-fmt,statix,deadnix}`, one per call | all pass (no Go changed) | this commit | +| V012 | 2026-10-24 | KC2-T01 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0, first run | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` (V004) and `NLCAP_KIND_RC 2`: `add_stp_mode_abbrev` and `add_stp_mode_bad` exit 1, not the planned 255 (see the decision log). Every other row held its status; the 39 options' values read back from each row's `-d -j` readback | scratch | +| V013 | 2026-10-24 | KC2-T01 | the same, with the two statuses corrected; `-d -j` readbacks checked against the values sent; compared with V012 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone; no `NLCAP_KIND_RC`. 60 kind rows: 50 at rc 0, 5 at 2, 2 at 1 and 3 at 255. All 39 options read back as sent, `group_fwd_mask` as `0x8` and `mcast_hash_elasticity` as 16 (the kernel ignores it). A refused add reads back nothing, its pcaps answering `-ENODEV`. 225 pcaps under `kinds/`: 60 `ip.pcap` and 55 rows × 3 readbacks. Against V012: 964 the same shape, 15 differing in V004's classes; the `kinds/` pcaps differ only in sequence numbers, port IDs and br0's random address | scratch | +| V014 | 2026-10-24 | KC2-T02 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 51 passed, 0 failed (the 102 rows, one `set_` row per value-taking port option, the port states and pres); the other suites unchanged | this commit | +| V015 | 2026-10-24 | KC2-T02 | mutation, scratch copies of the corpus and its test, through `expect`: `mab` dropped from the option list, `mab`'s row without `locked on`, `state`'s row without `p0` up, `set_on_off_bad` at 255, `set_not_enslaved` on an enslaved `p0` | 5 of 5 killed: by `slug_list`, `row_count`, `slave_option_rows` and `slave_mab_locked`; `slave_mab_locked`; `slave_state_pre`; `refused_statuses` and `slave_on_off_vs_value`; `slave_not_enslaved_pre` | scratch | +| V016 | 2026-10-24 | KC2-T02 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks checked against the values sent; compared with V013 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 42 rows hold their planned statuses on the first run: 30 at 0, 7 at 2, 2 at 1 and 3 at 255. 25 of the 26 port options read back as sent (`group_fwd_mask` as `0x8`, `backup_port` as `p1`); `state 4` reads back `forwarding` (see the decision log). `set_backup_port_missing` has 4 datagrams, its name lookup, before `invarg`. Against V013: 153 new pcaps, all under `kinds/bridge_slave/` (42 `ip.pcap` and 37 rows × 3 readbacks); 965 the same shape, 14 differing in V004's classes, none under `kinds/` | scratch | +| DOC004 | 2026-10-24 | KC2 close | KC3 broken into KC3-T01 (vlan), KC3-T02 (macvlan and macvtap) and KC3-T03 (ipvlan and ipvtap) with their tables, argued from `iplink_vlan.c`, `iplink_macvlan.c` and `iplink_ipvlan.c` at `v7.2.0` and each kind's kernel checks; doccheck | tasks one to one, no broken link or anchor | this commit | +| V017 | 2026-10-24 | KC3-T01 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 63 passed, 0 failed (the 130 rows, one `add_` row per vlan flag, the id range, `set_id`'s and the duplicate's pres, the no-link and no-id rows); the other suites unchanged | this commit | +| V018 | 2026-10-24 | KC3-T01 | mutation, scratch copies of the corpus and its test, through `expect`: a flag row dropped, `add_id_over` at 4094, `set_id`'s pre without `v0`, `add_qos_no_colon` at 255 | 4 of 4 killed: by `slug_list`, `row_count` and `vlan_flag_rows`; `vlan_id_range`; `vlan_set_id_pre`; `refused_statuses` and `vlan_returned_vs_exit` | scratch | +| V019 | 2026-10-24 | KC3-T01 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks checked against the values sent; compared with V016 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 28 rows hold their planned statuses on the first run: 15 at 0, 6 at 2, 3 at 1 and 4 at 255. Every id, protocol, flag and map reads back as sent; `reorder_hdr` is on unless turned off, and `set_id` still reads id 10. The kernel's refusals name themselves ("VLAN properties not specified", "link not specified", "Invalid VLAN id", "Invalid VLAN protocol", "VLAN device already exists", "VLANs not supported on device"). Against V016: 91 new pcaps, all under `kinds/vlan/` (28 `ip.pcap` and 21 rows × 3 readbacks); 1118 the same shape, 14 differing in V004's classes, none under `kinds/` | scratch | +| V020 | 2026-10-24 | KC3-T02 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 75 passed, 0 failed (the 170 rows, one `add_mode_` row per macvlan mode, the source and passthru pres, `bclim`'s negative and its `strcmp`, every macvtap row a macvtap); the other suites unchanged | this commit | +| V021 | 2026-10-24 | KC3-T02 | mutation, scratch copies of the corpus and its test, through `expect`: a mode dropped, `add_bclim_abbrev` at 0, `set_macaddr_del`'s pre without the address, a macvtap row made a macvlan row, a macvtap row sending `type macvlan` | 5 of 5 killed: by `slug_list`, `row_count`, `macvlan_after_vlan` and `macvlan_mode_rows`; `refused_statuses` and `macvlan_bclim_strcmp`; `macvlan_source_pre`; `slug_list` and `unique_slugs`; `macvtap_kind` | scratch | +| V022 | 2026-10-24 | KC3-T02 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks checked against the values sent; compared with V019 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 40 rows hold their planned statuses on the first run: macvlan 20 at 0, 5 at 2, 8 at 1 and 1 at 255; macvtap 5 at 0 and 1 at 1. Every mode, flag, `bcqueuelen`, `bclim` and source address reads back as sent; the default mode is `vepa`, `bclim` is printed only when set, and `macaddr set`'s two addresses read back in the reverse order. The kernel's refusals are bare errnos (`Invalid argument`, and `Cannot assign requested address` for the multicast source). Against V019: 130 new pcaps, all under `kinds/macvlan/` and `kinds/macvtap/` (40 `ip.pcap` and 30 rows × 3 readbacks); 1208 the same shape, 15 differing in V004's classes, none under `kinds/` | scratch | +| V023 | 2026-10-24 | KC3-T03 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 87 passed, 0 failed (the 192 rows, one `add_mode_` row per ipvlan mode and one `add_` row per flag word, the two-flag refusal, the busy lower's and `set_mode`'s pres, every ipvtap and macvtap row its own kind); the other suites unchanged | this commit | +| V024 | 2026-10-24 | KC3-T03 | mutation, scratch copies of the corpus and its test, through `expect`: a flag dropped, `add_two_flags` at 2, `add_on_busy_lower`'s pre without `m0`, an ipvtap row sending `type ipvlan`; and V021's macvtap mutant again, against the shared `strays` | 5 of 5 killed: by `slug_list`, `row_count`, `ipvlan_after_macvtap` and `ipvlan_flag_rows`; `refused_statuses` and `ipvlan_two_flags`; `ipvlan_busy_pre`; `ipvtap_kind`; `macvtap_kind` | scratch | +| V025 | 2026-10-24 | KC3-T03 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks checked against the values sent; compared with V022 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 22 rows hold their planned statuses on the first run: ipvlan 11 at 0, 3 at 2, 3 at 1 and 1 at 255; ipvtap 3 at 0 and 1 at 1. Every mode and flag reads back as sent; the default mode is `l3`, no flag reads back `"bridge": true`, and `set_mode` reads back with `private` cleared. The busy lower is `Device or resource busy`. Against V022: 73 new pcaps, all under `kinds/ipvlan/` and `kinds/ipvtap/` (22 `ip.pcap` and 17 rows × 3 readbacks); 1340 the same shape, 13 differing in V004's classes, none under `kinds/` | scratch | +| DOC005 | 2026-10-24 | KC3 close | KC4 broken into KC4-T01 (vxlan), KC4-T02 (geneve), KC4-T03 (bareudp and amt) and KC4-T04 (gtp) with their tables, argued from `iplink_vxlan.c`, `iplink_geneve.c`, `iplink_bareudp.c`, `iplink_amt.c` and `iplink_gtp.c` at `v7.2.0` and each kind's kernel checks; the geneve `local` wording corrected and KC0's gtp refusal explained (decision log); doccheck | tasks one to one, no broken link or anchor | this commit | +| V026 | 2026-10-24 | KC4-T01 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 97 passed, 0 failed (the 265 rows, one `add_` row per vxlan flag and one `add_df_` row per value, the VNI range, the rows without a dstport, `d0` before every `dev d0`, the duplicate's and the sets' pres, `get_addr`'s 1 against `invarg`'s 255); the other suites unchanged | this commit | +| V027 | 2026-10-24 | KC4-T01 | mutation, scratch copies of the corpus and its test, through `expect`: a flag dropped, `add_id_over` at 2, `add_group`'s pre without `d0`, `add_remote_bad` at 255 | 4 of 4 killed: by `slug_list`, `row_count` and `vxlan_flag_rows`; `refused_statuses` and `vxlan_id_range`; `vxlan_dev_pre`; `refused_statuses` and `vxlan_get_addr` | scratch | +| V028 | 2026-10-24 | KC4-T01 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks diffed against `add_dstport`'s; compared with V025 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 73 rows hold their planned statuses on the first run: 46 at 0, 12 at 2, 8 at 1 and 7 at 255. Each row's readback differs from the base in the attribute it sent and no other, but as the plan has it: no dstport reads back 8472, `gpe` 4790; `ageing none` 300; `ttl inherit` drops the `ttl` key; `tos inherit` reads `0x1`; `mcroute` reads back nothing; `gbp`, `gpe` and `remcsumrx` change `reserved_bits` too (the default is `0xf7ffffff000000ff`). The kernel's refusals carry their extack text ("Cannot change VNI", "Cannot change port", "cannot change PROXY flag", "Invalid source port range", …). Against V025: 247 new pcaps, all under `kinds/vxlan/` (73 `ip.pcap` and 58 rows × 3 readbacks); 1412 the same shape, 14 differing in V004's classes, none under `kinds/` | scratch | +| V029 | 2026-10-24 | KC4-T02 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 104 passed, 0 failed (the 314 rows, `local` the one address keyword with a row per family, `local6` and `loc` refused by `ip`, the conflicting rows' pres and readbacks, the VNI range, every set row carrying an `id`); the other suites unchanged | this commit | +| V030 | 2026-10-24 | KC4-T02 | mutation, scratch copies of the corpus and its test, through `expect`: `add_local6` sent as `local6`, `add_local_abbrev` at 0, `add_after_external`'s pre made with base, `add_dstport_zero` dropped | 4 of 4 killed: by `geneve_local_one_keyword`; `refused_statuses` and `geneve_local_refused`; `geneve_conflict_pres`; `slug_list` and `row_count` | scratch | +| V031 | 2026-10-24 | KC4-T02 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks diffed against `add_default`'s; compared with V028 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 49 rows hold their planned statuses on the first run: 22 at 0, 13 at 2, 7 at 1 and 7 at 255. Each accepted row's readback differs from the base in what it sent, with two quirks: `dstport 0` reads back 6081, because `ip` sends `IFLA_GENEVE_PORT` only when it is nonzero, and an IPv6 remote turns `udp_csum` into `udp_zero_csum6_tx`/`_rx`. `set_on_external` is refused with `EOPNOTSUPP`, and the other refused sets carry "Changing VNI, Port, endpoint IP address family, external, …". Against V028: 154 new pcaps, all under `kinds/geneve/` (49 `ip.pcap` and 35 rows × 3 readbacks); 1658 the same shape, 15 differing in V004's classes, none under `kinds/` | scratch | +| V032 | 2026-10-24 | KC4-T03 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 112 passed, 0 failed (the 350 rows, bareudp's missing option `ip`'s and amt's the kernel's, the busy port's pre and readbacks, the two refused sets on a device their pre made, every amt row the kernel answers but two naming a lower, `matches` against `strcmp`); the other suites unchanged | this commit | +| V033 | 2026-10-24 | KC4-T03 | mutation, scratch copies of the corpus and its test, through `expect`: bareudp `add_no_ethertype` at 1, amt `add_no_link` with `dev d0`, bareudp `add_port_busy`'s pre without `b0`, amt `add_abbrev` at 0 | 4 of 4 killed: by `refused_statuses`; `amt_lower_excluded`; `bareudp_busy_pre`; `refused_statuses` and `keyword_matching` | scratch | +| V034 | 2026-10-24 | KC4-T03 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks; compared with V031 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 36 rows hold their planned statuses on the first run: bareudp 6 at 0, 4 at 2, 1 at 1, 5 at 255; amt 5 at 0, 8 at 2, 3 at 1, 4 at 255. The readbacks are the plan's: `srcportmin` 0 by default, `ipv4` as `ip`, `0x1234` as `[4660]`; amt's ports 2268 and `max_tunnels` 128 (also for `max_tunnels 0`), a relay's `discovery` 0.0.0.0, and `mode gateway mode relay` as relay. The kernel's refusals carry their extack text ("Another bareudp device using the same port already exists", "Cannot set multiproto mode for this ethertype", "Link attribute is required", "Invalid stream device type", "relay port must not be 0", "discovery must be unicast", …); amt's bare add is a plain `Invalid argument`, and both sets `Operation not supported`. Against V031: 105 new pcaps, all under `kinds/bareudp/` and `kinds/amt/` (36 `ip.pcap` and 23 rows × 3 readbacks); 1814 the same shape, 13 differing in V004's classes, none under `kinds/` | scratch | +| V035 | 2026-10-24 | KC4-T04 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 118 passed, 0 failed (the 369 rows, gtp's after amt's, the hash size's top, an unknown word skipped against an abbreviated role, the second device's pre and readbacks, every set on the `g0` its pre made); the other suites unchanged | this commit | +| V036 | 2026-10-24 | KC4-T04 | mutation, scratch copies of the corpus and its test, through `expect`: `add_unknown_skipped` at 1, `add_hsize_max` as 131072, `add_second` without its pre | 3 of 3 killed: by `refused_statuses` and `gtp_unknown_vs_abbrev`; `gtp_hsize_top`; `gtp_second_pre` | scratch | +| V037 | 2026-10-24 | KC4-T04 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks; compared with V034 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 19 rows hold their planned statuses on the first run: 7 at 0, 4 at 2, 3 at 1 and 5 at 255. The readbacks are the plan's: `role` as sent, `hsize` 1024 by default and for `hsize 0`, 131071 at the top, `restart_count` 255, and `foo` skipped. The kernel refuses the bare add and the bare set with a plain `Invalid argument` (validate runs with no data), the second socket-owning device with `Address already in use`, and the set with `Operation not supported` (gtp has no `changelink`); `ip`'s refusals carry its role and `invarg` texts. Against V034: 52 new pcaps, all under `kinds/gtp/` (19 `ip.pcap` and 11 rows × 3 readbacks); 1921 the same shape, 11 differing in V004's classes, none under `kinds/` | scratch | +| DOC006 | 2026-10-24 | KC4 close | KC5 broken into KC5-T01 (gre, gretap and erspan), KC5-T02 (ip6gre, ip6gretap and ip6erspan), KC5-T03 (ipip and sit), KC5-T04 (ip6tnl) and KC5-T05 (vti, vti6 and xfrm) with their tables, argued from `link_gre.c`, `link_gre6.c`, `link_iptnl.c`, `link_ip6tnl.c`, `link_vti.c`, `link_vti6.c` and `link_xfrm.c` at `v7.2.0` and each kind's kernel checks; the fou/fou6 and 6RD findings (decision log); doccheck | tasks one to one, no broken link or anchor | this commit | +| V038 | 2026-10-24 | KC5-T01 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 129 passed, 0 failed (the 450 rows, gre's after gtp's, a bare `type ` accepted by all three, erspan's index, key and hwid ranges, gre's ttl and tos tops, the remote going to any, the prefetch's three sets, the conflict rows' pres and readbacks, `d0` first, the two key exits, the foreign words); the other suites unchanged | this commit | +| V039 | 2026-10-24 | KC5-T01 | mutation, scratch copies of the corpus and its test, through `expect`: gretap `set_remote_any` at 2, erspan `add_index_over` at 0, gre `set_pmtudisc_ignore_df`'s pre without `ignore-df`, gre `add_key_bad` at 1 | 4 of 4 killed: by `refused_statuses` and `remote_any`; `refused_statuses` and `erspan_ranges`; `conflict_pres`; `refused_statuses` and `key_exits` | scratch | +| V040 | 2026-10-24 | KC5-T01 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks; compared with V037 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 81 rows hold their planned statuses on the first run: gre 24 at 0, 7 at 2, 8 at 1, 6 at 255; gretap 6 at 0, 4 at 2, 1 at 1; erspan 14 at 0, 5 at 2, 1 at 1, 5 at 255. The readbacks are the plan's: keys dotted (`0.0.0.42`, `1.2.3.4`, a multicast group's own), `ttl inherit` as JSON `ttl` 0, `pmtudisc` true once any word is sent and false for a bare `type `, `encap fou` with `encap-sport auto` as `sport` 0 and the later `noencap-csum` winning, `external` reading back `remote any`, the prefetched set on an external `gr0` still `ttl` 0, gretap's remote going to any, erspan's bare add with `okey 0.0.0.0`, and `erspan_dir` ingress and `erspan_hwid` 0 when not sent. The kernel's refusals are `File exists`, `Invalid argument` (the ignore-df and erspan flag checks, gre's point-to-point change), `Cannot assign requested address`, `Operation not supported` and the collect_md extack; `ip`'s carry the usage text, "A broadcast tunnel requires a source address." and "Failed to get existing tunnel info.". Against V037: 261 new pcaps, all under `kinds/gre/`, `kinds/gretap/` and `kinds/erspan/` (81 `ip.pcap` and 60 rows × 3 readbacks); 1967 the same shape, 17 differing in V004's classes (`tcp_metrics` and neighbor dumps, monitor rows), none under `kinds/` | scratch | +| V041 | 2026-10-24 | KC5-T02 | `nix build .#checks.x86_64-linux.vm-lib-exp`, run twice: the first run failed one row of the address-flag suite this task doesn't touch (a first-poll match reporting `elapsed=1s` under host load), and the second passed | `kind-write-corpus.exp`: 141 passed, 0 failed (the 514 rows, ip6gre's after erspan's, no repeat check, the tops `ip` and the kernel take, the bare rows and their acceptance, `erspan_ver 0` on the two kinds, the two key exits, the collision rows' pres, readbacks and set statuses, `d0` first, the port-swap pre); the other suites unchanged | this commit | +| V042 | 2026-10-24 | KC5-T02 | mutation, scratch copies of the corpus and its test, through `expect`: ip6gre `add_repeat` at 255, ip6erspan `add_ver_zero` at 0, ip6gre `set_collide`'s pre without `g6`, ip6gre `add_key_dotted_bad` at 255 | 4 of 4 killed: by `refused_statuses` and `ip6gre_no_repeat_check`; `refused_statuses` and `erspan_ver_zero`; `ip6_collision_pres`; `refused_statuses` and `ip6gre_key_exits` | scratch | +| V043 | 2026-10-24 | KC5-T02 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks; compared with V040 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The first attempt was stopped for host memory pressure while still building, before the guest booted, and the rerun is the evidence. The 64 rows hold their planned statuses on the first run: ip6gre 21 at 0, 5 at 2, 5 at 1, 5 at 255; ip6gretap 6 at 0, 3 at 2; ip6erspan 9 at 0, 6 at 2, 1 at 1, 3 at 255. The readbacks are the plan's: hoplimit 64 and encaplimit 4 once any word is sent, and `ttl` 0 with `encap_limit` 0 for a bare `type ` or `external`; a bare ip6erspan reads back no `erspan_*` key; `encaplimit none` as `ip6_tnl_f_ign_encap_limit`; the repeated `hoplimit` as its last value; keys dotted; the prefetched set keeping the pre's values. `encap fou` and `encap gue` are `Invalid argument` (fou6 isn't loaded); the other kernel refusals are `File exists`, `Invalid argument` (erspan's version, flag, key and hwid checks) and `Operation not supported`; `ip`'s carry the usage text, `get_addr`'s message and "Failed to get existing tunnel info.". Against V040: 214 new pcaps, all under `kinds/ip6gre/`, `kinds/ip6gretap/` and `kinds/ip6erspan/` (64 `ip.pcap` and 50 rows × 3 readbacks); 2231 the same shape, 14 differing in V004's classes (`tcp_metrics` and neighbor dumps, monitor rows), none under `kinds/` | scratch | +| V044 | 2026-10-24 | KC5-T03 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 154 passed, 0 failed (the 598 rows, ipip's after ip6erspan's, the mode rows, the ttl tops, the prefetched ttl, sit's own words, the 6RD rows, the sets the kernel doesn't apply, the pres, the second readbacks, sit's wildcard prefetch, no `6rd-reset`, `d0` first, the exits), after the first VM run's two fixes (decision log); the other suites unchanged | this commit | +| V045 | 2026-10-24 | KC5-T03 | mutation, scratch copies of the corpus and its test, through `expect`: sit `add_6rd_overlong` at 2, ipip `add_isatap` at 0, ipip `set_nopmtudisc_ttl`'s pre without `ttl`, ipip `add_duplicate` without its pre; after the first VM run's fixes, `6rd-reset` added to a sit row, sit `set_remote_on_inherit`'s pre made bare, sit `set_external_wildcard` at 0 | 7 of 7 killed: by `refused_statuses` and `sit_6rd`; `refused_statuses` and `sit_words`; `iptnl_prefetch_ttl`; `iptnl_pres`; `no_6rd_reset`; `sit_wildcard_prefetch`; `refused_statuses` and `sit_wildcard_prefetch` | scratch | +| V046 | 2026-10-25 | KC5-T03 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0, twice; `-d -j` readbacks; compared with V043 by `pcapcmp` | The first run failed three sit rows besides V004's: `add_6rd_reset` (`ip` segfaulted, `MISSED FLOOR`) and `set_external_wildcard` and `set_remote_on_wildcard` (255, "ttl != 0 and nopmtudisc are incompatible", not 0 and 2). Both causes are `ip`'s and are in the decision log; the corpus dropped the first row, moved the other two to 255 and added `set_remote_on_inherit`. The second run is the evidence: `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004), and the 84 rows hold their statuses: ipip 26 at 0, 8 at 2, 5 at 1, 8 at 255; sit 20 at 0, 6 at 2, 5 at 1, 6 at 255. The readbacks are the plan's: a bare ipip `ipip … ttl inherit nopmtudisc`, a bare sit `ip6ip … ttl 64 nopmtudisc`, `add_default` `any … ttl inherit pmtudisc`, `external` on ipip only (sit's reads back an ordinary `ttl 64 pmtudisc` tunnel), `tos inherit`, a set of `mode` or `isatap` leaving `any` and no `isatap`, the 6rd rows reading back nothing. The kernel's refusals are `File exists` and `Invalid argument`; `ip`'s carry the usage text, `get_addr`'s and `get_prefix`'s messages, `invarg`'s and the ttl/nopmtudisc message. Against V043: 264 new pcaps, all under `kinds/ipip/` and `kinds/sit/` (84 `ip.pcap` and 60 rows × 3 readbacks); 2443 the same shape, 16 differing in V004's classes (`tcp_metrics` and neighbor dumps, monitor and event rows), none under `kinds/` | scratch | +| V047 | 2026-10-25 | KC5-T04 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 163 passed, 0 failed (the 647 rows, ip6tnl's after sit's, the mode rows, the label's and hop limit's tops, the encap words, the prefetch row's pre, a set of `mode`, the conflict pres, the second readbacks, `d0` first); the other suites unchanged | this commit | +| V048 | 2026-10-25 | KC5-T04 | mutation, scratch copies of the corpus and its test, through `expect`: ip6tnl `add_encap_abbrev` at 1, `set_keeps_prefetch`'s pre without `hoplimit 10`, `add_flowlabel_over` at 0, `add_external_second`'s pre made with base | 4 of 4 killed: by `refused_statuses` and `ip6tnl_encap_words`; `ip6tnl_prefetch_pre`; `refused_statuses` and `ip6tnl_tops`; `ip6tnl_pres` | scratch | +| V049 | 2026-10-25 | KC5-T04 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks; compared with V046 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 49 rows hold their planned statuses on the first run: 29 at 0, 7 at 2, 3 at 1, 10 at 255. The readbacks are the plan's: a bare add `remote any` with `ttl` 0 and `encap_limit` 0, `add_default` hoplimit 64 and encaplimit 4, `encaplimit none` as `ip6_tnl_f_ign_encap_limit`, the `inherit` words as their `ip6_tnl_f_*` flags, `hoplimit 10 ttl 20` as 20, the prefetched set keeping hoplimit 10 beside the new tclass, a set of `mode` applied (`ipip6`), `external` reading back `remote any`. `encap fou` is `Invalid argument` (fou6 isn't loaded); the other kernel refusals are `File exists` and `Invalid argument`; `ip`'s carry the usage text, `get_addr`'s message, `invarg`'s, `nodev`'s and `NEXT_ARG`'s. Against V046: 157 new pcaps, all under `kinds/ip6tnl/` (49 `ip.pcap` and 36 rows × 3 readbacks); 2710 the same shape, 13 differing in V004's classes (`tcp_metrics` and neighbor dumps, monitor and event rows), none under `kinds/` | scratch | +| V050 | 2026-10-25 | KC5-T05 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 178 passed, 0 failed (the 705 rows, vti's after ip6tnl's and then vti6's and xfrm's, the abbreviations, the key's and if_id's ranges, vti's point-to-point check that vti6 lacks, the lower and the key in the conflicts, what a set keeps or drops, the prefetch's failure, the bare sets, the conflict pres and readbacks, the `strcmp` words, `d0` first and its negative, `ip`'s xfrm checks); the other suites unchanged. The draft's "three `set_no_option` rows" test had no vti6 row in its table, so the table gains vti6 `set_no_option` | this commit | +| V051 | 2026-10-25 | KC5-T05 | mutation, scratch copies of the corpus and its test, through `expect`: vti `set_remote_on_any` at 0, vti6 `add_duplicate_other_dev`'s pre without `v6`, xfrm `set_drop_dev`'s command with `dev d0`, vti `set_on_dummy` at 2 | 4 of 4 killed: by `refused_statuses` and `vti_p2p`; `vti_conflict_pres`; `vti_set_keeps`; `refused_statuses` and `vti_prefetch_fail` | scratch | +| V052 | 2026-10-25 | KC5-T05 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks; compared with V049 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 58 rows hold their planned statuses on the first run: vti 11 at 0, 5 at 2, 3 at 1, 4 at 255; vti6 7 at 0, 2 at 2, 2 at 1, 1 at 255; xfrm 9 at 0, 8 at 2, 2 at 1, 4 at 255. The readbacks are the plan's: keys as dotted quads on both vti kinds (`255.255.255.255` for the top, `ikey 0.0.0.6 okey 0.0.0.5` when `ikey` follows `key`), `fwmark` and `if_id` in hex, `remote`/`local` `any` when unset, the prefetched sets keeping the pre's remote, vti6's remote going to and from any, and xfrm's `external` alone. The kernel's refusals are `File exists`, `Invalid argument` (vti's point-to-point change, xfrm's lower change), "if_id must be non zero." and "collect_md can't be changed."; `ip`'s carry the usage text, `get_addr`'s message, "Failed to get existing tunnel info.", xfrm's external check, `invarg`'s, `missarg`'s, `nodev`'s and `NEXT_ARG`'s. Against V049: 184 new pcaps, all under `kinds/vti/`, `kinds/vti6/` and `kinds/xfrm/` (58 `ip.pcap` and 42 rows × 3 readbacks); 2867 the same shape, 13 differing in V004's classes (`tcp_metrics` and neighbor dumps, monitor and event rows), none under `kinds/` | scratch | +| DOC007 | 2026-10-25 | KC5 close | KC6 broken into KC6-T01 (hsr), KC6-T02 (netkit), KC6-T03 (vrf, vrf_slave and vxcan) and KC6-T04 (batadv, ifb, nlmon, vcan, team and team_slave) with their tables, argued from `iplink_hsr.c`, `iplink_netkit.c`, `iplink_vrf.c`, `iplink_vxcan.c`, `iplink_batadv.c`, `iplink_ifb.c`, `iplink_nlmon.c`, `iplink_vcan.c` and `iplink_team.c` at `v7.2.0` and each kind's kernel checks; the `nodev`, bare-set, parser-less, hsr check, netkit statics and vxcan usage findings and the batadv attribute count (decision log); doccheck | tasks one to one, no broken link or anchor | this commit | +| V053 | 2026-10-25 | KC6-T01 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 186 passed, 0 failed (the 734 rows, hsr's after xfrm's, the supervision byte's top, `ip`'s u8 checks, the bare add, `sl` and `inter`, every row's `d0` and `d1`, the interlink rows' `d2`, no `changelink`); the other suites unchanged | this commit | +| V054 | 2026-10-25 | KC6-T01 | mutation, scratch copies of the corpus and its test, through `expect`: `add_version_2` at 255, `add_abbrev`'s pre without `d1`, `set_option` at 0 | 3 of 3 killed: by `refused_statuses` and `hsr_u8_checks`; `hsr_pres`; `refused_statuses` and `hsr_no_changelink` | scratch | +| V055 | 2026-10-25 | KC6-T01 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks; compared with V052 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 29 rows hold their planned statuses on the first run: 10 at 0, 10 at 2, 4 at 1, 5 at 255. The readbacks are the plan's: `slave1` and `slave2`, `supervision_addr` `01:15:4e:00:01:00` by default and `…:2a`, `…:ff` and `…:02` (the last of two) when sent, `seq_nr` 64511, `version` 0 or 1, PRP's `proto` 1 with no `version`, `interlink d2`, and the abbreviations' `…:07` with version 1. `version 2` and `proto 2` pass `ip` and are the kernel's "Only HSR version 0/1 supported." and "Unsupported protocol."; the other refusals are hsr's extacks (no slaves, a missing slave, the same slave twice, the interlink as a slave, PRP's version, loopback) and `Operation not supported` for the set; `ip`'s carry the usage text and `invarg`'s and `NEXT_ARG`'s messages. Against V052: 89 new pcaps, all under `kinds/hsr/` (29 `ip.pcap` and 20 rows × 3 readbacks); 3053 the same shape, 11 differing in V004's classes, none under `kinds/` | scratch | +| V056 | 2026-10-25 | KC6-T02 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 194 passed, 0 failed (the 777 rows, netkit's after hsr's, the peer name's length, `peer` splitting the policy and scrub words, the policies alone changing on a set, the address on l2 alone and before `type`, `strcmp`, the sets naming `nt1` and its readbacks); the other suites unchanged | this commit | +| V057 | 2026-10-25 | KC6-T02 | mutation, scratch copies of the corpus and its test, through `expect`: `set_mode` at 0, `add_l2_address` without `mode l2`, `add_peer_name_then_word` at 0 | 3 of 3 killed: by `refused_statuses` and `netkit_set_policies`; `netkit_address`; `refused_statuses` and `netkit_peer_split`. The second survived the first draft of `netkit_address`, which checked only that `address` came before `type`; the check now reads the words after `type` too | scratch | +| V058 | 2026-10-25 | KC6-T02 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks; compared with V055 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 43 rows hold their planned statuses on the first run: 19 at 0, 8 at 2, 8 at 1, 8 at 255. The readbacks are the plan's: l3, forward and scrub default on both ends by default; `mode l2` alone, after `peer`, and with an address (sent before `type`); `blackhole` (the last of two policies) before `peer` and `peer_policy blackhole` after; `scrub none` and `peer_scrub none`; the sets changing `policy` and `peer_policy` alone, and the bare sets changing nothing. The kernel's refusals are `Operation not supported` for an address in l3 (on either end), `Cannot assign requested address` for a multicast address, `File exists` for a peer named like the device, and netkit's extacks for a set changing the mode, the scrub or the peer, or made through the peer; `ip`'s are `nodev`'s `Cannot find device`, netkit's usage and its mode and scrub messages, `NEXT_ARG`, the peer name's length, "both name and dev", and `duparg`, which prints `"(null)"` for a second `peer` or `type`. Against V055: 124 new pcaps, all under `kinds/netkit/` (43 `ip.pcap` and 27 rows × 3 readbacks); 3138 the same shape, 15 differing in V004's classes, none under `kinds/` | scratch | +| V059 | 2026-10-25 | KC6-T03 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 202 passed, 0 failed (the 823 rows, vrf's, vrf_slave's and vxcan's after netkit's, the table's range, the bare sets and the refused sets of the kinds with no `changelink`, vrf_slave's missing parser, the enslave rows' pres and `d0` readbacks, the missing devices, the vxcan sets' `vc1` pres); the other suites unchanged | this commit | +| V060 | 2026-10-25 | KC6-T03 | mutation, scratch copies of the corpus and its test, through `expect`: `vrf/set_table` at 0, `vrf_slave/set_master`'s pre without `vr0`, `vxcan/set_peer_nodev` at 255 | 3 of 3 killed: by `refused_statuses` and `no_changelink_sets`; `vrf_slave_pres`; `refused_statuses` and `missing_device_exits` | scratch | +| V061 | 2026-10-25 | KC6-T03 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks; compared with V058 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 46 rows hold their planned statuses on the first run: vrf 8 at 0, 4 at 2, 2 at 1, 4 at 255; vrf_slave 4 at 0, 3 at 2, 1 at 1, 2 at 255; vxcan 6 at 0, 3 at 2, 4 at 1, 5 at 255. The readbacks are the plan's: vrf's `table` 10, 16 for `0x10`, 254 for `main`, 4294967295, 20 for the last of two, 10 beside a `vr1` with the same table and after the bare set; `d0` reads back `info_slave_kind` vrf with `table` 10 after `master`, `vrf` and the bare `type vrf_slave`, and no slave kind after `nomaster`; vxcan's peer is the kernel's `vxcan0` by default and `vc1` when named, and `up` applies to `vc0`. The kernel's refusals are "VRF table id is missing." and "Invalid VRF table id.", `Invalid hardware address.`, `Unknown device type.` for a vrf_slave add, the loopback and L3-master enslavements, `Operation not supported` for the sets with data, `File exists` for a peer named like the device and `Transport endpoint is not connected` for `peer up`; `ip`'s are the table's `invarg`s, `NEXT_ARG`, `Garbage`, the usages (vxcan's on stdout, so its 1s have an empty `ip.err`), `nodev`, "dev required", the peer name's length and `duparg`. Against V058: 130 new pcaps, all under `kinds/vrf/`, `kinds/vrf_slave/` and `kinds/vxcan/` (46 `ip.pcap` and 28 rows × 3 readbacks); 3260 the same shape, 17 differing in V004's classes, none under `kinds/` | scratch | +| V062 | 2026-10-25 | KC6-T04 | `nix build .#checks.x86_64-linux.vm-lib-exp` | `kind-write-corpus.exp`: 211 passed, 0 failed (the 886 rows, batadv's, ifb's, nlmon's, vcan's, team's and team_slave's after vxcan's, vcan's MTUs, nlmon's minimum, the parser-less kinds' words and `help`, the generic words before `type`, the bare sets of any kind, batadv's kernel-only check, a team port's state, the team rows' pres); the other suites unchanged | this commit | +| V063 | 2026-10-25 | KC6-T04 | mutation, scratch copies of the corpus and its test, through `expect`: `vcan/set_mtu_76` at 2, `team/set_enslave_up` without `up`, `ifb/add_help_abbrev` at 1, `team/set_enslave`'s pre without `tm0` | 4 of 4 killed: by `refused_statuses` and `vcan_mtus`; `team_port_down`; `refused_statuses` and `parserless_words`; `team_pres` | scratch | +| V064 | 2026-10-25 | KC6-T04 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200`, guest kernel 7.3.0; `-d -j` readbacks; compared with V061 by `pcapcmp` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004). The 63 rows hold their planned statuses on the first run: batadv 6 at 0, 6 at 2, 4 at 1, 1 at 255; ifb 4, 1, 3, 1; nlmon 3, 2, 1, 0; vcan 8, 3, 2, 0; team 6, 5, 1, 2; team_slave 2, 1, 1, 0. The readbacks are the plan's: batadv, ifb, nlmon, vcan and team read back their kind and no `info_data`; nlmon's MTU 3776 by default and 16 at the minimum; vcan's 2060 by default, 16 and 100 at create, 72 and 76 on a set (and 72 with `up`), and 2060 after each refused set; `d0` reads back `master tm0` and `info_slave_kind` team after the enslavements and the bare `type team_slave`, and neither after `nomaster` or on a free `d0`. The kernel's refusals are `Invalid argument` for batadv's unknown and lowercase names (on a set too), nlmon's address and vcan's 75 and 2061, `Operation not supported` for batadv's set, `File exists`, `mtu less than device minimum.`, `Cannot assign requested address`, `Device or resource busy` for an up vcan, `Unknown device type.` and team's three extacks; `ip`'s are `Garbage`, the usages, batadv's unknown commands, `nodev`, `NEXT_ARG` and `invarg`. Against V061: 204 new pcaps, all under the six kinds' directories (63 `ip.pcap` and 47 rows × 3 readbacks); 3390 the same shape, 17 differing in V004's classes, none under `kinds/` | scratch | +| DOC008 | 2026-10-25 | KC6 close | KC7 broken into KC7-T01 (the 7.3.0 link-write corpus) and KC7-T02 (the gate) with their tables; the decision to commit a 7.3.0 corpus beside 7.1.4's; doccheck | tasks one to one, no broken link or anchor | this commit | +| V065 | 2026-10-25 | KC7-T01 | `nix run .#microvm-x86_64-link-write-capture -- --timeout 7200` with the default `--out`, guest kernel 7.3.0; compared with V064 by `pcapcmp` and every `ip.rc` | `NLCAP_DRIVER_FAIL` on `genl/mptcp_limits_set_over` alone (V004), which lands with the kernel's 0. 10558 files, 38 MB, in `pkg/xtcpnl/testdata/7_3_0/link-write`; `uname` `Linux xtcp2-vm-x8664 7.3.0`, `ip_version` iproute2-7.2.0. Against V064: no pcap new or missing, 3600 the same shape, 11 differing in V004's classes, none under `kinds/`; every `ip.rc` the same. `7_1_4/` untouched | this commit | +| V066 | 2026-10-25 | KC7-T01 | `go vet` and `go test -count=1` over `./pkg/xtcpnl/`, `./pkg/nlparity/`, `./internal/gordma/` and `./pkg/linkmonitor/...` | all pass: `TestKindRowsComplete` (the committed corpus and 8 built rows), `TestCorpusAllSkipsLinkWrite730` (4 rows), the two new provenance rows, `TestNetlinkUniverse` with no stale exclusion. Before `corpusAll` skipped the 7.3.0 set, 17 corpus tests failed, their tallies doubled (or, for nsid, grown by the new set's share): the reason for the skip | this commit | +| V067 | 2026-10-25 | KC7-T01 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: rc 2 on the no-readback side; the extra-readback case dropped | 2 of 2 killed: by `boundary: a kernel refusal is read back too`; `corner: an ip refusal that was read back`. The skip's mutant is V066's 17 failures | scratch | +| V068 | 2026-10-25 | KC7-T01 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass (all three lint tiers exit 0) | this commit | +| V069 | 2026-10-25 | KC7-T02 | `go vet` and `go test -count=1` over `./pkg/xtcpnl/` | all pass: `TestLinkKindUniverse` (the committed corpus, 39 of 46 kinds captured and 7 excluded, and 7 built rows) and `TestLinkKindCaptured` (10 built rows) | this commit | +| V070 | 2026-10-25 | KC7-T02 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: the stale-exclusion case dropped; the `_slave` match dropped; the `ip.rc` filter dropped | 3 of 3 killed: by `corner: an exclusion the corpus contradicts`; `positive: the committed corpus passes` (bond_slave, bridge_slave, team_slave and vrf_slave uncaptured) and the rows built on it; `boundary: refused rows alone are not` | scratch | +| V071 | 2026-10-25 | KC7-T02 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass (all three lint tiers exit 0) | this commit | +| DOC009 | 2026-10-25 | KC7 close | KP0 broken into KP0-T01 (the `inet` devconf codec) with its tables, argued from `iplink_parse_inet` at `v7.2.0`, `inet_devconf_policy`, `inet_validate_link_af` and `inet_fill_link_af`, and the KC1 rows' requests and readbacks; KC7-T02's section amended to what the gate does (46 kinds, the shared readbacks); doccheck | tasks one to one, no broken link or anchor | this commit | +| V072 | 2026-10-25 | KP0-T01 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/`, and `go test -bench BenchmarkParseInetDevconf -benchmem` | all pass: `TestInetDevconfParams`, `TestInetDevconfEncode` (the 11 KC1 rows byte for byte and 8 built rows), `TestInetDevconfRequestParse` (the 11 rows decoded and round-tripped, and 5 built rows), `TestInetDevconfValues` (5 corpus rows and 3 built). `request` 519 ns/op, 504 B/op, 6 allocs/op; `values` 119 ns/op, 144 B/op, 1 alloc/op | this commit | +| V073 | 2026-10-25 | KP0-T01 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: `NLA_F_NESTED` dropped; repeats deduplicated; id `i` read at index `i` | 3 of 3 killed: by the 11 byte-identical rows; `corner: a repeated id is kept` and `inet_repeated`'s rows; `positive: every devconf id reads back what inet_every_param sent` and three more | scratch | +| V074 | 2026-10-25 | KP0-T01 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass (all three lint tiers exit 0; the first lint run found a gofmt alignment in the test, fixed) | this commit | +| DOC010 | 2026-10-25 | KP0 close | KP1 broken into KP1-T01 (bridge) and KP1-T02 (bridge_slave) with their tables, argued from `iplink_bridge.c` and `iplink_bridge_slave.c` at `v7.2.0`, `if_link.h`'s ids and the KC2 rows' readbacks; doccheck | tasks one to one, no broken link or anchor | this commit | +| V075 | 2026-10-25 | KP1-T01 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/`, `go test -race ./internal/goip/...`, `go test ./pkg/linkmonitor/... ./pkg/localnet/ ./pkg/rtnlmon/`, and `go test -bench BenchmarkParseBridgeInfoData -benchmem` | all pass: `TestBridgeDumpMatchesReadback` (50 rows), `TestParseBridgeInfoData` (12 rows), `TestEncodeBridgeInfoData` (55 corpus rows and 9 built). `pkg/linkmonitor`'s 4 `route-1` replay goldens regenerated with `-update`: 8 digests change, every value count is the same. 1717 ns/op, 1056 B/op, 5 allocs/op (4726 ns and 14 allocs with bond's map, before the array). `pkg/xtcp` doesn't link (giouring); lint type-checks it | this commit | +| V076 | 2026-10-25 | KP1-T01 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: `vlan_protocol` read little-endian; the boolopt put in argument order; `stp_mode` dropped from `BridgeOptions` | 3 of 3 killed: by all 50 `TestBridgeDumpMatchesReadback` rows (and the 802.1ad row); `corner: the boolopt goes last`, `corner: a later boolopt word …` and `add_boolopts`'s request; all 50 readback rows | scratch | +| V077 | 2026-10-25 | KP1-T01 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass. The first lint run found misspell's `ageing` in the keyword and `AGEING` in x/sys/unix's id, and an `exhaustive` switch in the test, all fixed without an exclusion | this commit | +| V078 | 2026-10-25 | KP1-T02 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test ./pkg/linkmonitor/...`, and `go test -bench 'BenchmarkParseBridge' -benchmem` | all pass: `TestBridgeSlaveDumpMatchesReadback` (30 rows), `TestParseBridgeSlaveData` (12 rows), `TestEncodeBridgeSlaveData` (37 corpus rows and 7 built), `TestLinkInfoBridgeKinds` (4 rows), and KP1-T01's 135 bridge subtests on the shared `kindRows` harness. The 4 `route-1` replay goldens regenerated again: 8 digests change, every value count is the same. 1384 ns/op, 912 B/op, 3 allocs/op for the port; the bridge 1857 ns/op, 1056 B/op, 5 allocs/op | this commit | +| V079 | 2026-10-25 | KP1-T02 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: `flood` mapped to `IFLA_BRPORT_MCAST_FLOOD`; `IflaBrportNeighForwardGrat` 44; the `setLinkInfoNest` branch keyed on `Kind` | 3 of 3 killed: by `set_flood`'s and `set_mcast_flood`'s readback rows and `positive: a port's dump`; by every readback row whose dump carries attribute 44 or 45 and `positive: the 7.2.0 attribute`; by `TestLinkInfoBridgeKinds`' port row and `TestParseNewLinkRealFixture` | scratch | +| V080 | 2026-10-25 | KP1-T02 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass. The first lint run found goconst on six keywords the two option tables (and the devconf table's `proxy_arp`) share, and a staticcheck QF1002 in the test's wire switch; the keywords became constants and the switch a tagged one, with no exclusion | this commit | +| DOC011 | 2026-10-25 | KP1 close | KP2 broken into KP2-T01 (vlan), KP2-T02 (macvlan and macvtap) and KP2-T03 (ipvlan and ipvtap) with their tables, argued from `iplink_vlan.c`, `iplink_macvlan.c` and `iplink_ipvlan.c` at `v7.2.0` and the KC3 rows' readbacks; doccheck | tasks one to one, no broken link or anchor | this commit | +| V081 | 2026-10-25 | KP2-T01 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test ./pkg/linkmonitor/...`, and `go test -bench BenchmarkParseVlanInfoData -benchmem` | all pass: `TestVlanDumpMatchesReadback` (15 rows), `TestParseVlanInfoData` (13 rows), `TestEncodeVlanInfoData` (21 corpus rows and 9 built). The 4 `route-1` replay goldens regenerated: 8 digests change, every value count is the same. 151 ns/op, 136 B/op, 3 allocs/op | this commit | +| V082 | 2026-10-25 | KP2-T01 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: the protocol read little-endian; the egress nest decoded as ingress; the egress nest left unknown; the flags emitted in argument order | 4 of 4 killed: by every readback row; by both egress readback rows and `positive: the egress map` (twice); by `corner: flags go last`, `corner: two flags, one attribute`, `corner: a later word for a bit wins` and `add_several`'s request | scratch | +| V083 | 2026-10-25 | KP2-T01 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass. The first lint run found a `prealloc` in the test's keyword list, fixed | this commit | +| V084 | 2026-10-25 | KP2-T02 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test ./pkg/linkmonitor/...`, and `go test -bench BenchmarkParseMacvlanInfoData -benchmem` | all pass: `TestMacvlanDumpMatchesReadback` (25 rows of both kinds), `TestParseMacvlanInfoData` (11 rows), `TestEncodeMacvlanInfoData` (30 corpus rows and 10 built), `TestLinkInfoMacvlanKinds` (4 rows). The 4 `route-1` replay goldens regenerated: 8 digests change, every value count is the same. 168 ns/op, 136 B/op, 3 allocs/op | this commit | +| V085 | 2026-10-25 | KP2-T02 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: passthru and source swapped; the macvtap branch dropped; the mode emitted in argument order | 3 of 3 killed: by every passthru and source readback row; by `positive: a macvtap decodes Macvlan`; by `corner: mode and flags go last`, `corner: the last mode wins` and the requests with `macaddr` after `mode` | scratch | +| V086 | 2026-10-25 | KP2-T02 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass. The first lint run found a `prealloc` in the test's mode list, fixed with `slices.Sorted(maps.Keys(…))` | this commit | +| V087 | 2026-10-25 | KP2-T03 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test ./pkg/linkmonitor/...`, and `go test -bench BenchmarkParseIpvlanInfoData -benchmem` | all pass: `TestIpvlanDumpMatchesReadback` (14 rows of both kinds), `TestParseIpvlanInfoData` (11 rows), `TestEncodeIpvlanInfoData` (17 corpus rows and 8 built), `TestLinkInfoIpvlanKinds` (4 rows). The 4 `route-1` replay goldens regenerated: 8 digests change, every value count is the same. 29 ns/op, 32 B/op, 1 alloc/op | this commit | +| V088 | 2026-10-25 | KP2-T03 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: `IFLA_IPVLAN_FLAGS` sent only with a flag word; vepa winning over private in the render; the ipvtap branch dropped | 3 of 3 killed: by `positive: a mode alone sends flags 0`, `corner: each mode word is its own attribute` and every mode-only request; by `corner: both flag bits render private alone`; by `positive: an ipvtap decodes Ipvlan`. A first attempt at the flags mutant changed only the error check, an equivalent mutant; it survived and was rerun as planned | scratch | +| V089 | 2026-10-25 | KP2-T03 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass. The first lint run found ST1022 on `IpvlanModes`' comment, fixed | this commit | +| DOC012 | 2026-10-25 | KP2 close | KP3 broken into KP3-T01 (vxlan), KP3-T02 (geneve), KP3-T03 (bareudp and amt) and KP3-T04 (gtp) with their tables, argued from `iplink_vxlan.c`, `iplink_geneve.c`, `iplink_bareudp.c`, `iplink_amt.c` and `iplink_gtp.c` at `v7.2.0` and the KC4 rows' readbacks; doccheck | tasks one to one, no broken link or anchor | this commit | +| V090 | 2026-10-25 | KP3-T01 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test ./pkg/linkmonitor/...`, and `go test -bench BenchmarkParseVxlanInfoData -benchmem` | all pass: `TestVxlanDumpMatchesReadback` (46 rows), `TestParseVxlanInfoData` (14 rows), `TestEncodeVxlanInfoData` (58 corpus rows and 11 built), `TestLinkInfoVxlanKinds` (4 rows). The 4 `route-1` replay goldens regenerated: 8 digests change, every value count is the same. 304 ns/op, 176 B/op, 1 alloc/op | this commit | +| V091 | 2026-10-25 | KP3-T01 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: the port read little-endian; `LEARNING` sent on a set; the group/remote address check inverted | 3 of 3 killed: by every readback row; by `corner: a set sends no learning by default`, `boundary: a zero source range sends nothing` and every `set_*` request but `set_changeable`; by `negative: a group without dev`, `negative: a multicast remote` and the `add_group`, `add_group6`, `add_remote` and `add_local` requests | scratch | +| V092 | 2026-10-25 | KP3-T01 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass. The first lint run found misspell on the kernel's `ageing` (the keyword, the JSON key and `IFLA_VXLAN_AGEING`); the keyword became `VxlanKeywordAgeing`, the id `IflaVxlanAgeing`, and the field `Aging`, with no exclusion | this commit | +| V093 | 2026-10-25 | KP3-T02 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test ./pkg/linkmonitor/...`, and `go test -bench BenchmarkParseGeneveInfoData -benchmem` | all pass: `TestGeneveDumpMatchesReadback` (22 rows), `TestParseGeneveInfoData` (14 rows), `TestEncodeGeneveInfoData` (35 corpus rows and 10 built), `TestLinkInfoGeneveKinds` (4 rows); the vxlan tests unchanged after the helpers moved to `xtcpnl_kind_wire.go`. The 4 `route-1` replay goldens regenerated: 8 digests change, every value count is the same. 158 ns/op, 112 B/op, 1 alloc/op | this commit | +| V094 | 2026-10-25 | KP3-T02 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: `LOCAL6` sent as `LOCAL`; `LABEL` sent on a set; `external`'s id dropped | 3 of 3 killed: by the `add_local6`, `add_external_local6`, `add_local6_link_local` and `add_local_family_mix` requests; by `corner: a set sends only what it was given` and every `set_*` request without a label; by the `add_external`, `add_external_local`, `add_external_local6` and `add_external_udpcsum` requests | scratch | +| V095 | 2026-10-25 | KP3-T02 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| V096 | 2026-10-25 | KP3-T03 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test -race ./pkg/linkmonitor/...`, and `go test -bench 'BenchmarkParse(Bareudp\|Amt)InfoData' -benchmem` | all pass: `TestBareudpDumpMatchesReadback` (6 rows), `TestParseBareudpInfoData` (8 rows), `TestEncodeBareudpInfoData` (10 corpus rows and 8 built), `TestAmtDumpMatchesReadback` (5 rows), `TestParseAmtInfoData` (10 rows), `TestEncodeAmtInfoData` (13 corpus rows and 8 built), `TestLinkInfoBareudpAmtKinds` (4 rows); the vxlan and geneve tests pass with `ErrKindSettingMissing`. The 4 `route-1` replay goldens regenerated: 8 digests change. bareudp 82 ns/op, 48 B/op; amt 217 ns/op, 128 B/op; 1 alloc/op each | this commit | +| V097 | 2026-10-25 | KP3-T03 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: bareudp's ethertype read little-endian; amt's mode names swapped; amt's `LINK` not decoded | 3 of 3 killed: by all 6 bareudp readback rows and `TestParseBareudpInfoData`; by all 5 amt readback rows and `positive: a gateway's dump`; by all 5 amt readback rows and `corner: an unnamed lower prints as if` | scratch | +| V098 | 2026-10-25 | KP3-T03 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| V099 | 2026-10-25 | KP3-T04 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test -race ./pkg/linkmonitor/...`, and `go test -bench BenchmarkParseGtpInfoData -benchmem` | all pass: `TestGtpDumpMatchesReadback` (7 rows), `TestParseGtpInfoData` (9 rows), `TestEncodeGtpInfoData` (11 corpus rows and 8 built), `TestLinkInfoGtpKinds` (4 rows). The 4 `route-1` replay goldens regenerated: 8 digests change. 47 ns/op, 48 B/op, 1 alloc/op | this commit | +| V100 | 2026-10-25 | KP3-T04 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: the plan's three (the default hash size dropped; the roles swapped; `CREATE_SOCKETS` omitted), and two more (the default hash size always sent; `RESTART_COUNT` not decoded) | 5 of 5 killed: by `add_ggsn`'s and every request without `hsize`; by all 7 readback rows; by every request; by the `hsize` requests and `corner: an explicit hash size is not repeated`; by `add_restart_count`'s readback and `positive: a restart count` | scratch | +| V101 | 2026-10-25 | KP3-T04 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass; lint-baseline on the second run, after `gofmt` wrapped a long line in `xtcpnl_gtp_test.go` | this commit | +| DOC013 | 2026-10-25 | KP3 close | KP4 broken into KP4-T01 (gre, gretap, erspan), KP4-T02 (ip6gre, ip6gretap, ip6erspan), KP4-T03 (ipip, sit), KP4-T04 (ip6tnl) and KP4-T05 (vti, vti6, xfrm) with their tables, argued from `link_gre.c`, `link_gre6.c`, `link_iptnl.c`, `link_ip6tnl.c`, `link_vti.c`, `link_vti6.c` and `link_xfrm.c` at `v7.2.0`, the 7.3 kernel's `fill_info`s and the KC5 rows; "Where goip stands" gains the KP1 to KP3 kinds; doccheck | tasks one to one, no broken link or anchor | this commit | +| V102 | 2026-10-25 | KP4-T01 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test -race ./pkg/linkmonitor/...`, and `go test -bench BenchmarkParseGreInfoData -benchmem` | all pass: `TestGreDumpMatchesReadback` (44 rows), `TestParseGreInfoData` (13 rows), `TestEncodeGreInfoData` (60 corpus rows and 11 built), `TestLinkInfoGreKinds` (4 rows). The 4 `route-1` replay goldens regenerated: 8 digests change. 205 ns/op, 176 B/op, 1 alloc/op | this commit | +| V103 | 2026-10-25 | KP4-T01 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: the plan's three (the `GRE_KEY` bit read little-endian; the prefetched port not swapped; erspan's index sent at version 2) | 3 of 3 killed: by the keyed readback and request rows; by `corner: a set re-sends the prefetched port swapped`; the third survived the plan's table (no corpus row gives an index at version 2) and is killed by the added `corner: an index at version 2 is not sent` | scratch | +| V104 | 2026-10-25 | KP4-T01 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass; lint-baseline on the second run, after `default:` arms in `greKeyWord`'s switches (exhaustive) and an argument-order rewrite in the test's `erspan_dir` parse (gocritic) | this commit | +| V105 | 2026-10-25 | KP4-T02 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test -race ./pkg/linkmonitor/...`, and `go test -bench BenchmarkParseGre6InfoData -benchmem` | all pass: `TestGre6DumpMatchesReadback` (36 rows), `TestParseGre6InfoData` (9 rows), `TestEncodeGre6InfoData` (50 corpus rows and 11 built), `TestLinkInfoGre6Kinds` (4 rows). The replay goldens don't change: `LinkInfo.Gre` already covered the ip6 kinds. 207 ns/op, 176 B/op, 1 alloc/op | this commit | +| V106 | 2026-10-25 | KP4-T02 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: the tclass shifted by 24; `ERSPAN_VER` sent only with an erspan word; the prefetched `FLAGS` without the `CAP` bits | 3 of 3 killed: by `add_tops`, `add_abbrev` and `set_changeable`'s readbacks, `boundary: the tops` and the `tclass` corner row; by every ip6gre request and the built rows; by every set's request and `corner: the prefetch's CAP bits go back` | scratch | +| V107 | 2026-10-25 | KP4-T02 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| V108 | 2026-10-25 | KP4-T03 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test -race ./pkg/linkmonitor/...`, and `go test -bench BenchmarkParseIptunInfoData -benchmem` | all pass: `TestIptunDumpMatchesReadback` (46 rows), `TestParseIptunInfoData` (9 rows), `TestEncodeIptunInfoData` (60 corpus rows and 9 built), `TestLinkInfoIptunKinds` (4 rows); the plan's first parse row failed until it expected `ip`'s default proto, `any`. The 4 `route-1` replay goldens regenerated: 8 digests change. 168 ns/op, 208 B/op, 1 alloc/op | this commit | +| V109 | 2026-10-25 | KP4-T03 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: `PROTO` sent after `LOCAL`; the `ttl`/`nopmtudisc` check skipping a set's seeded values; `ISATAP` not seeded | 3 of 3 killed: by every request with an endpoint and `corner: ipip drops the 6rd words`; by `corner: a set's seeded ttl with nopmtudisc`; by `corner: a set keeps a prefetched isatap`. The first attempt at the `PROTO` mutant didn't compile and was rerun | scratch | +| V110 | 2026-10-25 | KP4-T03 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| V111 | 2026-10-25 | KP4-T04 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test -race ./pkg/linkmonitor/...`, and `go test -bench BenchmarkParseIp6tnlInfoData -benchmem` | all pass: `TestIp6tnlDumpMatchesReadback` (29 rows), `TestParseIp6tnlInfoData` (10 rows), `TestEncodeIp6tnlInfoData` (36 corpus rows and 8 built), `TestLinkInfoIp6tnlKinds` (4 rows); `TestEncodeGre6InfoData` and `TestEncodeIptunInfoData` still pass on the shared `ip6Tnl` state. The replay goldens don't change: `LinkInfo.Iptun` already covered ip6tnl. 187 ns/op, 208 B/op, 1 alloc/op | this commit | +| V112 | 2026-10-25 | KP4-T04 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: the encap seeded from `cur`; `encaplimit none` clearing the limit; the flow label masked to 16 bits | 3 of 3 killed: by `corner: a set resets the encap`; by `add_encaplimit_none`'s and `set_changeable`'s requests (and ip6gre's `add_encaplimit_none`); by `add_flowlabel`'s and `set_changeable`'s requests (and ip6gre's `add_tops`) | scratch | +| V113 | 2026-10-25 | KP4-T04 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass; lint-baseline on the second run, after ipip's and ip6tnl's proto names share `iptunProtoAnyCst` (goconst) | this commit | +| V114 | 2026-10-25 | KP4-T05 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test -race ./pkg/linkmonitor/...`, and `go test -bench 'BenchmarkParse(Vti|XfrmLink)InfoData' -benchmem` | all pass: `TestVtiDumpMatchesReadback` (18 rows), `TestParseVtiInfoData` (7 rows), `TestEncodeVtiInfoData` (25 corpus rows and 7 built), `TestXfrmLinkDumpMatchesReadback` (9 rows), `TestParseXfrmLinkInfoData` (6 rows), `TestEncodeXfrmLinkInfoData` (17 corpus rows and 8 built), `TestLinkInfoTunnelKinds` (4 rows); the vti6 dump row first expected `d0` at ifindex 2 and was corrected to 3. The 4 `route-1` replay goldens regenerated: 8 digests change. vti 90 ns/op, 112 B/op, 1 alloc/op; xfrm 34 ns/op, 48 B/op, 1 alloc/op | this commit | +| V115 | 2026-10-25 | KP4-T05 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: the vti `LINK` seeded whole; xfrm's `IF_ID` held to after the loop; vti's `REMOTE` sent when unset or zero | 3 of 3 killed: by `corner: the prefetched link keeps its low byte`; by `corner: two if_ids send two IF_IDs` and `add_if_id_twice`'s request; by `corner: remote any drops a prefetched remote` and both `set_remote_any` requests | scratch | +| V116 | 2026-10-25 | KP4-T05 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| DOC014 | 2026-10-25 | KP4 close | KP5 broken into KP5-T01 (hsr), KP5-T02 (netkit), KP5-T03 (vrf, vrf_slave and vxcan) and KP5-T04 (batadv and the kinds with no parser) with their tables, argued from `iplink_hsr.c`, `iplink_netkit.c`, `iplink_vrf.c`, `iplink_vxcan.c` and `iplink_batadv.c` at `v7.2.0`, the 7.3 kernel's `hsr_fill_info`, `netkit_fill_info` and `vrf_fillinfo`, and the KC6 rows; KP4-T05's tables match the built rows; "Where goip stands" gains the KP4 kinds; `coverage-status.md`'s `IFLA_INFO_DATA` row, false since KP1, names the kinds now decoded; doccheck | tasks one to one, no broken link or anchor | this commit | +| V117 | 2026-10-25 | KP5-T01 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test -race ./pkg/linkmonitor/...`, and `go test -bench BenchmarkParseHsrInfoData -benchmem` | all pass: `TestHsrDumpMatchesReadback` (10 rows), `TestParseHsrInfoData` (7 rows), `TestEncodeHsrInfoData` (20 corpus rows and 8 built), `TestLinkInfoHsrKinds` (4 rows). The 4 `route-1` replay goldens regenerated: 8 digests change. 77 ns/op, 64 B/op, 1 alloc/op | this commit | +| V118 | 2026-10-25 | KP5-T01 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: the version refused above 1; the short-attribute check dropped; the attributes sent in id order | 3 of 3 killed: by `corner: version 2 passes ip` and `add_version_2`'s request; by `negative: a short SLAVE1 hides everything`; by `add_prp_version`'s request alone at first, since the plan's `add_abbrev` words are already in id order, and, after the word-order row was rebuilt out of id order, by it too | scratch | +| V119 | 2026-10-25 | KP5-T01 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| V120 | 2026-10-25 | KP5-T02 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test -race ./pkg/linkmonitor/...`, and `go test -bench BenchmarkParseNetkitInfoData -benchmem` | all pass: `TestNetkitDumpMatchesReadback` (19 rows), `TestParseNetkitInfoData` (7 rows), `TestEncodeNetkitInfoData` (27 corpus rows and 11 built), `TestLinkInfoNetkitKinds` (4 rows), and the veth tests unchanged after `EncodeVethPeer` moved onto `putPeerNest`. The 4 `route-1` replay goldens regenerated: 8 digests change. 117 ns/op, 96 B/op, 1 alloc/op | this commit | +| V121 | 2026-10-25 | KP5-T02 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: a policy after `peer` sent as `POLICY`; a second `mode` accepted; `PEER_INFO` placed before the netkit words | 3 of 3 killed: by `corner: peer splits the policy words` and the `add_peer_blackhole` and `set_peer_policy` requests; by `negative: a second mode`; by `corner: PEER_INFO follows netkit's words`, added after the mutant survived, since no corpus row sends a netkit word and a peer name together | scratch | +| V122 | 2026-10-25 | KP5-T02 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| V123 | 2026-10-25 | KP5-T03 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, `go test -race ./pkg/linkmonitor/...`, and `go test -bench BenchmarkParseVrfInfoData -benchmem` | all pass: `TestVrfDumpMatchesReadback` (8 vrf rows and 3 vrf_slave), `TestEncodeVrfInfoData` (12 corpus rows and 6 built), `TestEncodeVxcanInfoData` (9 corpus rows and 5 built), `TestKindSlaveDataAbsent` (10 vrf_slave requests and 3 readbacks). No `LinkInfo` field changes, so the replay goldens are unchanged. 7.4 ns/op, 0 B/op, 0 allocs/op | this commit | +| V124 | 2026-10-25 | KP5-T03 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: only the first table sent; vxcan's peer nest skipped for a bare `peer`; the peer's flags dropped from `putPeerNest`'s header | 3 of 3 killed: by `corner: two tables send two attributes, in order` and `add_table_twice`'s request; by `boundary: peer alone` and `add_peer`'s request; by the peer-flags rows of veth, netkit and vxcan and the netkit and vxcan `add_peer_up` requests | scratch | +| V125 | 2026-10-25 | KP5-T03 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | lint-baseline failed first on the vrf test (gocritic `argOrder` on `strings.HasPrefix("table", kw)`, and `prealloc`), fixed by an explicit prefix compare and a sized slice; both then pass | this commit | +| V126 | 2026-10-25 | KP5-T04 | `go vet`, `go test -count=1 -race ./pkg/xtcpnl/ ./internal/goip/`, and `go test -bench BenchmarkEncodeBatadvInfoData -benchmem` | all pass: `TestEncodeBatadvInfoData` (12 corpus rows and 6 built), `TestKindsWithoutData` (46 ifb, nlmon, vcan, team and team_slave requests, 4 dumps and the bare set of another kind). No `LinkInfo` field changes, so the replay goldens are unchanged. 65 ns/op, 16 B/op, 1 alloc/op | this commit | +| V127 | 2026-10-25 | KP5-T04 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: the NUL dropped from `ALGO_NAME`; only the last name sent; an ifb given a decoder that returns non-nil | 3 of 3 killed: by every row sending a name; by `corner: two names send two attributes, in order` and `add_ra_twice`'s request; by `positive: an ifb's dump decodes no kind data` | scratch | +| V128 | 2026-10-25 | KP5-T04 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| DOC015 | 2026-10-25 | KP5 close | KG0 broken into KG0-T01 (`inet` in goip's keyword chain, with `ip`'s exit statuses through `ErrLinkUsage`) and KG0-T02 (the 11 matrix rows, gated) with their tables, argued from `iplink_parse_inet` and `parse_on_off` at `v7.2.0`, the KC1 rows' `ip.rc` and `ip.err`, and goip's `parseLinkArgs`, `exitCodeOf` and write-row rules; KP5-T04's tables match the built rows; "Where goip stands" gains the KP5 kinds; `coverage-status.md`'s `IFLA_INFO_DATA` row names KP5's kinds; doccheck | tasks one to one, no broken link or anchor | this commit | +| V129 | 2026-10-25 | KG0-T01 | `go vet`, `go test -count=1 -race ./internal/goip/ ./internal/goipparity/` | all pass: `TestLinkSetInetMatchesIp` (11 rows), `TestLinkSetInetRefusals` (7 corpus rows and 7 built), `TestLinkSetInetBuilt` (4 rows), `TestErrLinkUsageExitCode` (3 rows). One existing row moved from 1 to 255: `TestRunLinkArgs`' `l s`, whose "dev" argument is required refusal is `exit(-1)` in `ip` | this commit | +| V130 | 2026-10-25 | KG0-T01 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: the params matched by prefix; the pushed-back word consumed; values read base 10; `ErrLinkUsage` mapped to 1; `inet` given `prefix: true` | 5 of 5 killed: by `inet_abbreviated`; by `inet_then_mtu`, `inet_then_garbage` and the `inet` twice row; by `inet_tag_hex` and `inet_arp_ignore_octal`; by the six `ip`-refused rows at 255 and the numeric-given-`on` row; by `corner: ine is a device name` | scratch | +| V131 | 2026-10-25 | KG0-T01 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| V132 | 2026-10-25 | tooling | `go vet`, `go test -count=1 -race ./internal/goipparity/` with `-section` on `goip-parity commands` and `compare` | all pass: `TestSections` (the table's 24 sections, none empty), `TestCommandSection` (8 rows), `TestSelectSections` (11), `TestSectionLine` (4) and `TestRunSection` (7). `BenchmarkSelectSections` runs | this commit | +| V133 | 2026-10-25 | tooling | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: the spec ignored; compare left unfiltered; the section line dropped; options not skipped; `commands` given an empty spec; the spec checked but every row kept | 6 of 6 killed: by `TestSelectSections`; by `TestRunSection`'s compare row; by `TestSectionLine`; by `TestSections` and `TestCommandSection`; by `TestRunSection`'s commands rows; by `TestSelectSections` and `TestRunSection` | scratch | +| V134 | 2026-10-25 | tooling | `nix build .#checks.x86_64-linux.lint-baseline`, then `nix-fmt`, `statix`, `deadnix` and `vm-lib-exp`; then the runner built, with `--help`, `--section 'link;reboot'` and `--section=` | every check passes; `--help` lists `--section`; both bad lists exit 1 with `bad --section` before the VM boots | this commit | +| V135 | 2026-10-25 | KG0-T02 | `nix run .#microvm-x86_64-goip-parity -- --timeout 12000`, the full table, with the 11 `link_inet_*` rows ungated | `GOIP_PARITY_PASS` on 518 of 518 rows, the 11 among them; `OVERALL_PASS`, `HYGIENE_PASS`, `UNGATED_CLEAN`, `CONTROL_NOISY 1067`, `DRIVER_PASS`, rc 0 | VM run | +| V136 | 2026-10-25 | KG0-T02 | the same with `--section link`, the first VM run of `--section` | `GOIP_PARITY_SECTION link (48 of 518 rows)`; `GOIP_PARITY_PASS` on all 48, the 11 among them; `OVERALL_PASS`, `HYGIENE_PASS`, `UNGATED_CLEAN`, `CONTROL_NOISY 4`, `DRIVER_PASS`, rc 0; the verdict says section `link` only. 8 min 40 s from start to verdict | VM run | +| V137 | 2026-10-25 | KG0-T02 | the 11 rows added to `gated_commands` and `TestAllowlistCommitted`'s list; `go vet`, `go test -count=1 -race ./internal/goipparity/ ./pkg/nlparity/` | all pass: `TestInetMatrixRows` (5 subtests, `positive: every row is gated` added), `TestUngatedSurfaceIsNotVacuous` back to the one named row (`addr show dynamic`), `TestAllowlistCommitted` | this commit | +| V138 | 2026-10-25 | KG0-T02 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: a row dropped from `gated_commands`; a row's floor at 2; a row not a write; an `ip`-refused row added | 4 of 4 killed: by `positive: every row is gated` and `TestUngatedSurfaceIsNotVacuous`; by `boundary: each row is an implemented write with floor 4` (twice); by `negative: no ip-refused row is a matrix row`, the order row, the gated row and `TestRender` | scratch | +| V139 | 2026-10-25 | KG0-T02 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| DOC016 | 2026-10-25 | KG0 close | KG1 broken into KG1-T01 (the bridge words), KG1-T02 (the bridge_slave words), KG1-T03 and KG1-T04 (the two `-d` renders) and KG1-T05 (the 96 matrix rows, gated by two `--section link` runs) with their tables, argued from `iplink_bridge.c` and `iplink_bridge_slave.c` at `v7.2.0`, KC2's 102 rows (`ip.rc`, `ip.err`, `topology`, record counts) and the bond path in `internal/goip`; doccheck | tasks one to one, no broken link or anchor | this commit | +| V140 | 2026-10-25 | KG1-T01 | `go test -count=1 -race ./internal/goip/ ./internal/goipparity/`, `go vet ./internal/goip/` | `TestLinkBridgeWriteMatchesIp` (55 rows), `TestLinkBridgeRefusals` (11), `TestBridgeCorpusArgv` (60), `TestBridgeParseChain` (4) and `TestParseBridgeArgs` (4) pass; vet is clean | this commit | +| V141 | 2026-10-25 | KG1-T01 | 5 mutants, each in a scratch worktree under a 6G scope | all killed: every keyword by prefix (`add_stp_mode_abbrev`, `mst_en 1`), a boolopt as a u32 (`mst_enabled 256`), `invarg` exiting 1 (the four 255 rows), `mcast_querier` first (the chain's corner row), `ll_proto_a2n` with case (`802.1AD`) | this commit | +| V142 | 2026-10-25 | KG1-T01 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | netlink-audit passes; lint-baseline passes on the second run, after the parser walks a sub-slice (gosec G602 on `args[i]` after `i++`) and the refusal table is preallocated | this commit | +| V143 | 2026-10-25 | KG1-T02 | `go test -count=1 -race ./internal/goip/ ./internal/goipparity/`, `go vet ./internal/goip/` | `TestLinkBridgeSlaveWriteMatchesIp` (37 rows), `TestLinkBridgeSlaveRefusals` (11), `TestBridgeSlaveCorpusArgv` (42), `TestBridgeSlaveParseChain` (8) and `TestParseBridgeSlaveArgs` (9) pass; vet is clean | this commit | +| V144 | 2026-10-25 | KG1-T02 | 8 mutants, each in a scratch worktree under a 6G scope | 7 killed: `nobackup_port` dropped, the backup port's get skipped, `neigh_forward_grat` by prefix, on/off by prefix, the invarg texts swapped, an unknown port sent, `mcast_fast_leave` before `mcast_flood`. `cost` and `hairpin` swapped survives as an equivalent mutant: no word is a prefix of both, so their order can't matter | this commit | +| V145 | 2026-10-25 | KG1-T02 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | netlink-audit passes; lint-baseline passes on the second run, after `"state"` became a constant (goconst) | this commit | +| V146 | 2026-10-25 | KG1-T03 | `go test -count=1 -race ./internal/goip/... ./internal/goipparity/ ./pkg/xtcpnl/`, `go vet` | `TestLinkShowDetailBridgeMatchesSidecar` (100 readbacks and 7 rows), `TestBridgeDataRender` (9), `TestSprintTime64` (7), `TestLLProtoN2A` (6) and the changed `TestDetailRefusedForPerKindData` pass; vet is clean | this commit | +| V147 | 2026-10-25 | KG1-T03 | 8 mutants, each in a scratch worktree under a 6G scope, over `./internal/goip/...` | all killed: the address padded, a timer as a float, two options swapped, `%#x` of 0 as `0x0`, the slave gate opened, `sprint_time64`'s threshold, the boolopt mask ignored, the JSON key as the keyword. The first run over the subpackages reported every mutant surviving, because the script read any `ok` line as a pass; it now looks for `FAIL` | this commit | +| V148 | 2026-10-25 | KG1-T03 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | netlink-audit passes; lint-baseline passes on the second run, after `"all"` became `nameAllCst`, shared by the bond table and `LLProtoNames` (goconst) | this commit | +| V149 | 2026-10-25 | KG1-T04 | `go test -count=1 -race ./internal/goip/...`, `go vet ./internal/goip/...` | `TestLinkShowDetailBridgeSlaveMatchesSidecar` (60 readbacks, each with its gets, and 6 rows), `TestBridgeSlaveDataRender` (9), the bridge's readbacks through `runKindShow` and the two mesh rows of `TestDetailRefusedForPerKindData`, now positive, pass; vet is clean | this commit | +| V150 | 2026-10-25 | KG1-T04 | 8 mutants, each in a scratch worktree under a 6G scope, over `./internal/goip/...` | all killed: `backup_port` by index, the state's boundary, `group_fwd_mask_str` dropped, `mcast_fast_leave` with a JSON key, `addText` in the JSON, the `backup_port` get dropped, a mask name on the wrong bit, the unkeyed attributes printed | this commit | +| V151 | 2026-10-25 | KG1-T04 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| V152 | 2026-10-25 | KG1-T05 | the 96 rows added to `gated_commands` and `TestAllowlistCommitted`'s list; `go vet`, `go test -count=1 -race ./internal/goipparity/ ./pkg/nlparity/` | all pass: `TestBridgeMatrixRows` (6 subtests, `positive: every row is gated` among them), `TestCompareWriteRow` over the 92 rows' own KC2 captures, `TestUngatedSurfaceIsNotVacuous` back to `addr show dynamic` alone, `TestAllowlistCommitted` | this commit | +| V153 | 2026-10-25 | KG1-T05 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: a floor short by 2; a setup step dropped; a row dropped; an `ip`-refused row added; a read row's floor short by 2; a setup on an add; a row dropped from `gated_commands`; a row dropped from `TestAllowlistCommitted`'s list only | 8 of 8 killed: by the floor row; the setup row; the request-row row; the negative row; the read row; the setup row; the gated row, `TestUngatedSurfaceIsNotVacuous` and `TestAllowlistCommitted`'s earned row; `TestAllowlistCommitted`'s `nothing is gated that no measured run earned` row | scratch | +| V154 | 2026-10-25 | KG1-T05 | `nix run .#microvm-x86_64-goip-parity -- --timeout 12000 --section link`, the 96 rows ungated | `GOIP_PARITY_SECTION link (144 of 614 rows)`; `GOIP_PARITY_PASS` on all 144, the 96 among them; `OVERALL_PASS`, `HYGIENE_PASS`, `UNGATED_CLEAN`, `CONTROL_NOISY 4`, `DRIVER_PASS`, rc 0 | VM run | +| V155 | 2026-10-25 | KG1-T05 | the same again, the second consecutive `--section link` run, on the same ungated rows | `GOIP_PARITY_SECTION link (144 of 614 rows)`; `GOIP_PARITY_PASS` on all 144, the 96 among them; `OVERALL_PASS`, `HYGIENE_PASS`, `UNGATED_CLEAN`, `CONTROL_NOISY 8`, `DRIVER_PASS`, rc 0 | VM run | +| V156 | 2026-10-25 | KG1-T05 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass; the misspell finding on the British timer keyword in `commands.go` was fixed before the first clean run by splitting it, as `xtcpnl.BridgeKeywordAgeingTime` is | this commit | +| DOC017 | 2026-10-25 | KG1 close | KG2 broken into KG2-T01 (the `link` keyword and the vlan words), KG2-T02 (macvlan and macvtap), KG2-T03 (ipvlan and ipvtap), KG2-T04 (the three `-d` renders) and KG2-T05 (the 78 matrix rows) with their tables. Argued from `iplink.c`, `iplink_vlan.c`, `iplink_macvlan.c`, `iplink_ipvlan.c`, `lib/utils.c`'s `parse_mapping` and `lib/ll_addr.c` at `v7.2.0`, from KC3's 90 rows (`ip.rc`, `ip.err`, `topology`, record counts, readbacks) and from KP2's codec. doccheck | tasks one to one, no broken link or anchor | this commit | +| V157 | 2026-10-25 | KG2-T01 | `go vet ./internal/goip/`, `go test -count=1 -race ./internal/goip/... ./internal/goipparity/` | all pass: `TestLinkVlanWriteMatchesIp` (21 rows, each against its own KC3 capture), `TestLinkVlanRefusals` (7 corpus rows, 5 built), `TestParseLinkLower` (6 rows, the old `negative: l is link, not a device` row now its set corner), `TestVlanCorpusArgv` (28 rows), `TestParseVlanArgs` | this commit | +| V158 | 2026-10-25 | KG2-T01 | mutation, scratch worktrees, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: `IFLA_LINK` after `IFLA_IFNAME`; `link` resolved on a set; `nodev` as 255; the qos `invarg` naming the whole word; `on` read as off; `id` matched by `strcmp`. The last three ran again after the G602 rewrite of `parseVlanArgs` | 6 of 6 killed: by the lower-device row; `corner: a set drops it`; the missing-lower rows; `add_qos_bad` and the later-pair corner; the flag rows and `TestParseVlanArgs`; `add_abbrev` | scratch | +| V159 | 2026-10-25 | KG2-T01 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass; gosec's G602 on `parseVlanArgs`' `args[i+1]` was fixed before the first clean run by walking a sub-slice | this commit | +| V160 | 2026-10-25 | KG2-T02 | `go vet ./internal/goip/`, `go test -count=1 -race ./internal/goip/... ./internal/goipparity/` | all pass: `TestLinkMacvlanWriteMatchesIp` (25 macvlan and 5 macvtap rows, each against its own KC3 capture), `TestLinkMacvlanRefusals` (10 corpus rows, 7 built), `TestParseMacvlanArgs` (8 rows), `TestLLAddrA2NPart` (8 rows), `TestMacvlanCorpusArgv` (34 and 6 rows); the existing `llAddrA2N` rows unchanged over the wrapper | this commit | +| V161 | 2026-10-25 | KG2-T02 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: `bclim` by prefix; the `nopromisc` check inside the loop; `nodst` tried before `nopromisc`; the set loop's word consumed; the silent length failure warned in an add; the warning printed for every word that ends a set; the usage naming macvlan for macvtap; `llAddrCut` leaving the word whole | 8 of 8 killed: by `add_bclim_abbrev`; the nopromisc-first rows; `n` is `nopromisc`; the four set rows; the one-part-short row; the hex-word set row; macvtap's usage row; the `mode:x` and seven-part rows | scratch | +| V162 | 2026-10-25 | KG2-T02 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass; goconst (the `mode` and `bridge` literals, now `xfrmModeCst` and `kindBridgeCst`) and exhaustive (the flag-arm switch's `default:`) were fixed before the first clean run | this commit | +| V163 | 2026-10-25 | KG2-T03 | `go vet ./internal/goip/`, `go test -count=1 -race ./internal/goip/... ./internal/goipparity/` | all pass: `TestLinkIpvlanWriteMatchesIp` (14 ipvlan and 3 ipvtap rows, each against its own KC3 capture), `TestLinkIpvlanRefusals` (5 corpus rows, 4 built), `TestParseIpvlanArgs` (5 rows), `TestIpvlanCorpusArgv` (18 and 4 rows) | this commit | +| V164 | 2026-10-25 | KG2-T03 | mutation, scratch worktrees, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: a second flag word accepted; the mode compared without case; `help` dropped; the unknown-option text naming ipvlan for ipvtap | 4 of 4 killed: by `add_two_flags` and `bridge private`; `mode L2`; the help row; ipvtap's row. The first version of the first mutant didn't build (`flagGiven` unused) and was rewritten to keep it read | scratch | +| V165 | 2026-10-25 | KG2-T03 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| V166 | 2026-10-25 | KG2-T04 | `go vet ./internal/goip/...`, `go test -count=1 -race ./internal/goip/... ./internal/goipparity/` | all pass: `TestLinkShowDetailVlanFamilyMatchesSidecar` (the 54 accepted vlan, macvlan, macvtap, ipvlan and ipvtap rows, `-d` and `-d -j` each, 108 subtests with the capture's gets, and 7 table rows), `TestVlanFamilyDataRender` (13 rows) | this commit | +| V167 | 2026-10-25 | KG2-T04 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: the qos map without its line break; ipvlan's mode without its leading space; `802.1Q` for a missing protocol; `remotes` in every mode; vlan flags in bit order; `bclim` unsigned; vlan dropped from `renderableInfoData`; an unnamed flag bit in decimal | 8 of 8 killed: by the qos rows; every ipvlan row; the no-protocol render row; the outside-source row; `add_several` and the print-order row; `add_bclim_negative`; every vlan row; the unnamed-bit row | scratch | +| V168 | 2026-10-25 | KG2-T04 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| V169 | 2026-10-25 | KG2-T05 | `nix run .#microvm-x86_64-goip-parity -- --timeout 12000 --section link`, the 78 rows ungated | `GOIP_PARITY_SECTION link (222 of 692 rows)`; `GOIP_PARITY_PASS` on all 222, the 78 among them, the 14 kernel-refused rows too; no `FAIL` or `SKIP`; `OVERALL_PASS`, `HYGIENE_PASS`, `UNGATED_CLEAN`, `CONTROL_NOISY 4`, `DRIVER_PASS`, rc 0 | VM run | +| V170 | 2026-10-25 | KG2-T05 | the same again, the second consecutive `--section link` run, on the same ungated rows | `GOIP_PARITY_SECTION link (222 of 692 rows)`; `GOIP_PARITY_PASS` on all 222, the 78 among them; no `FAIL` or `SKIP`; `OVERALL_PASS`, `HYGIENE_PASS`, `UNGATED_CLEAN`, `CONTROL_NOISY 4`, `DRIVER_PASS`, rc 0 | VM run | +| V171 | 2026-10-25 | KG2-T05 | the 78 rows added to `gated_commands` and `TestAllowlistCommitted`'s list; `go vet`, `go test -count=1 -race ./internal/goipparity/ ./pkg/nlparity/` | all pass: `TestVlanFamilyMatrixRows` (7 subtests, `positive: every row is gated` among them), `TestBridgeMatrixRows` over `kindRowKind`'s table, `TestCompareWriteRow` over the 68 rows' own KC3 captures, `TestUngatedSurfaceIsNotVacuous`, `TestAllowlistCommitted`, whose `nothing is gated that no measured run earned` row failed until the list had the 78 | this commit | +| V172 | 2026-10-25 | KG2-T05 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: a floor short by 2; a setup step dropped; a row dropped; an `ip`-refused row added; `add_on_busy_lower` back to its corpus spelling; a read row's floor short by 2; a row dropped from `gated_commands` | 7 of 7 killed: by the floor row; the setup row; the request-row and gated rows; the request-row, negative, floor and gated rows and `TestUngatedSurfaceIsNotVacuous`; the unique row; the read row; the gated row, `TestUngatedSurfaceIsNotVacuous` and `TestAllowlistCommitted`'s earned row | scratch | +| V173 | 2026-10-25 | KG2-T05 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| DOC018 | 2026-10-25 | KG2 close | KG3 broken into KG3-T01 (the vxlan words, `ip -4`/`-6`'s default group, a kind's `dev` at 255), KG3-T02 (the geneve words), KG3-T03 (the bareudp, amt and gtp words), KG3-T04 (the five `-d` renders and vxlan's `-d -d`) and KG3-T05 (the 138 matrix rows) with their tables. Argued from `iplink_vxlan.c`, `iplink_geneve.c`, `iplink_bareudp.c`, `iplink_amt.c`, `iplink_gtp.c`, `lib/utils.c`'s `get_addr` and `include/utils.h`'s address types at `v7.2.0`, from KC4's 177 rows (`ip.rc`, `ip.err`, `topology`, record counts, readbacks) and from KP3's codec. KG2-T05's checklist box ticked. doccheck | tasks one to one, no broken link or anchor | this commit | +| V174 | 2026-10-25 | KG3-T01 | `go vet ./internal/goip/ ./pkg/xtcpnl/`, `go test -count=1 -race ./internal/goip/ ./pkg/xtcpnl/ ./internal/goipparity/` | all pass: `TestLinkVxlanWriteMatchesIp` (the 58 rows that sent a request, each against its own KC4 capture), `TestLinkVxlanWarning` (stderr against `ip.err`, 7 rows warn), `TestLinkVxlanRefusals` (the 15 `ip`-refused rows and 9 built), `TestVxlanCorpusArgv` (73 rows), `TestParseVxlanArgs` (13 rows), `TestParseVxlanArgsErrors` (5 rows), `TestEncodeVxlanInfoData`'s default-group rows; `TestBuildLinkWrite`'s not-implemented row moved to ipoib | this commit | +| V175 | 2026-10-25 | KG3-T01 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: `dev` sent after the loop; `nodev` as 1; the group/remote check after `NEXT_ARG`; `port` read as `dstport`; the warning dropped; `localbypass` by prefix; the default group sent with an address | 7 of 7 killed: by `add_group`, `add_group6`, `add_local6_link_local` and the `d` is dev row; `add_dev_missing` and the missing-dev error row; the value corner; the source-range row; 5 of the warning rows; `add_strcmp_abbrev`; the `-4` negative row and the encoder's local row | scratch | +| V176 | 2026-10-25 | KG3-T01 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass; the first lint run found misspell (`ageing`, now built from `xtcpnl.VxlanKeywordAgeing`) and goconst (`label`, `remote`, `hoplimit`, now vxlan consts), fixed before the passing run | this commit | +| V177 | 2026-10-25 | KG3-T02 | `go vet ./internal/goip/ ./pkg/xtcpnl/`, `go test -count=1 -race ./internal/goip/ ./pkg/xtcpnl/ ./internal/goipparity/` | all pass: `TestLinkGeneveWriteMatchesIp` (the 35 rows that sent a request, each against its own KC4 capture), `TestLinkGeneveRefusals` (the 14 `ip`-refused rows and 5 built), `TestGeneveCorpusArgv` (49 rows), `TestParseGeneveArgs` (8 rows), `TestParseGeneveArgsErrors` (6 rows), `TestEncodeGeneveInfoData`'s v4-mapped and multicast rows, and the vxlan tests over the shared helpers | this commit | +| V178 | 2026-10-25 | KG3-T02 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: `local` by prefix; `local6` taken as a keyword; a remote required on a set; `d` read as `df`; the missing-id check skipped on a set; the encoder back on netip's `IsMulticast` | 6 of 6 killed: by `add_local_abbrev` and the `l` is label row; `add_local6_word`; `set_changeable`, `set_local_same` and three kernel-refused sets; the `d` is dsfield row; `set_no_id` and the set-without-an-id row; the v4-mapped rows of both tables | scratch | +| V179 | 2026-10-25 | KG3-T02 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass on the first run | this commit | +| V180 | 2026-10-25 | KG3-T03 | `go vet ./internal/goip/ ./pkg/xtcpnl/`, `go test -count=1 -race ./internal/goip/ ./pkg/xtcpnl/ ./internal/goipparity/` | all pass: `TestLinkSmallOverlayWriteMatchesIp` (KC4's 10 bareudp, 13 amt and 11 gtp rows that sent a request), `TestLinkSmallOverlayRefusals` (the 21 corpus refusals, 5 built rows and the three `help`s, with `ip`'s texts and statuses), `TestSmallOverlayCorpusArgv`, `TestParseSmallOverlayArgs` (8 rows) and `TestParseSmallOverlayArgsErrors` (6 rows) | this commit | +| V181 | 2026-10-25 | KG3-T03 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: bareudp's two missargs swapped; amt's mode by prefix; amt's bad mode as an unknown word; gtp refusing a word it doesn't know; gtp's restart_count in base 0; amt sending a non-inet address | 6 of 6 killed, each by a named row | this commit | +| V182 | 2026-10-25 | KG3-T03 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass; the first lint run found goconst (`gateway_port` three times in amt, and amt's `mode` beside the existing `xfrmModeCst`), so amt now uses `amtGatewayPortCst` and `xfrmModeCst` | this commit | +| V183 | 2026-10-25 | KG3-T04 | `go vet ./internal/goip/... ./pkg/xtcpnl/`, `go test -count=1 -race ./internal/goip/... ./pkg/xtcpnl/ ./internal/goipparity/` | all pass: `TestLinkShowDetailUdpOverlayMatchesSidecar` (the 86 accepted rows' `-d` and `-d -j` readbacks with `ip`'s gets, and 11 named rows), `TestLinkShowDetailVxlanJSONBools` (6 rows and the 12 bools), `TestLinkShowDetailVxlanTwice` (3 rows), `TestUdpOverlayDataRender` (30 rows), and `TestResolveShow`'s new `-d -d` refusal row | this commit | +| V184 | 2026-10-25 | KG3-T04 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: `-d -d` read as `-d`; the bool's text from the keyword, not the JSON key; a default bool printed; geneve's zero local hidden; `tos inherit` in JSON; vxlan left out of `renderableInfoData`; amt's `dev` by index; the lower device's get dropped | 8 of 8 killed, each by a named row | this commit | +| V185 | 2026-10-25 | KG3-T04 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass; the first lint run found misspell (`ageing` in the render and its test), now built from `xtcpnl.VxlanKeywordAgeing` | this commit | +| V186 | 2026-10-26 | KG3-T05 | `nix run .#microvm-x86_64-goip-parity -- --timeout 12000 --section link`, the 138 rows ungated | `GOIP_PARITY_SECTION link (360 of 830 rows)`; `GOIP_PARITY_PASS` on all 360, the 138 among them, the 41 kernel-refused rows too, and `-d -d link show dev vx0` at 626 bytes on all three legs; no `FAIL` or `SKIP`; `OVERALL_PASS`, `HYGIENE_PASS`, `UNGATED_CLEAN`, `CONTROL_NOISY 4`, `DRIVER_PASS`, rc 0 | VM run | +| V187 | 2026-10-26 | KG3-T05 | the same again, the second consecutive `--section link` run, on the same ungated rows | `GOIP_PARITY_SECTION link (360 of 830 rows)`; `GOIP_PARITY_PASS` on all 360, the 138 among them, `-d -d` at 626 bytes on all three legs again; no `FAIL` or `SKIP`; `OVERALL_PASS`, `HYGIENE_PASS`, `UNGATED_CLEAN`, `CONTROL_NOISY 4`, `DRIVER_PASS`, rc 0 | VM run | +| V188 | 2026-10-26 | KG3-T05 | the 138 rows added to `gated_commands` and `TestAllowlistCommitted`'s list; `go vet`, `go test -count=1 -race ./internal/goipparity/ ./pkg/nlparity/` | all pass: `TestUdpOverlayMatrixRows` (7 subtests, `positive: every row is gated` among them), `TestVlanFamilyMatrixRows` and `TestBridgeMatrixRows` over `kindRowKind`'s table, `TestCompareWriteRow` over the 127 rows' own KC4 captures, `TestUngatedSurfaceIsNotVacuous`, `TestAllowlistCommitted` | this commit | +| V189 | 2026-10-26 | KG3-T05 | mutation, a scratch worktree, each `go test` in a `systemd-run --scope -p MemoryMax=6G -p MemorySwapMax=0` with `-timeout=180s`: a floor short by 2; a setup step dropped; a row dropped; an `ip`-refused row added; `set_local_new` back to its corpus spelling; a0's floor as 4; a row dropped from `gated_commands` | 7 of 7 killed: by the floor row; the setup row; the request-row row and the gated-coverage tests; the request-row, negative and floor rows and `TestUngatedSurfaceIsNotVacuous`; `TestCommandTable`'s unique-name row and the gated-coverage tests; the read row; the gated row, `TestUngatedSurfaceIsNotVacuous` and `TestAllowlistCommitted`'s earned row | scratch | +| V190 | 2026-10-26 | KG3-T05 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit` | both pass; the first lint run found misspell (`ageing`, vxlan's keyword, in three row names and their allowlist entries) and prealloc (the read rows' names), so the keyword is split as `vxlanAgeingCst`, as `bridgeAgeingTimeCst` is, and the names are preallocated | this commit | +| DOC019 | 2026-10-26 | KG3 close | KG4 broken into KG4-T01 (goip-parity sets `fb_tunnels_only_for_init_net=2` before its first row), KG4-T02 (the set prefetch and the gre, gretap and erspan words), KG4-T03 (the ip6gre family's words), KG4-T04 (ipip's and sit's), KG4-T05 (ip6tnl's), KG4-T06 (vti's, vti6's and xfrm's), KG4-T07 and KG4-T08 (the twelve `-d` renders) and KG4-T09 (the 272 matrix rows) with their tables. Argued from `link_gre.c`, `link_gre6.c`, `link_iptnl.c`, `link_ip6tnl.c`, `link_vti.c`, `link_vti6.c`, `link_xfrm.c`, `ip/tunnel.c` and `lib/utils.c` at `v7.2.0`, from KC5's 336 rows (`ip.rc`, `ip.err`, `topology`, record counts, readbacks), from KP4's codec and from `goip-parity.exp`'s `late_setup` call | doccheck | tasks one to one, no broken link or anchor | this commit | +| V191 | 2026-10-26 | KG4-T01 | `nix build .#checks.x86_64-linux.vm-lib-exp` | passes; `addr-topology-test.exp` 208 passed, 0 failed, with the four new rows: `no_fallback_tunnels` sets the sysctl in the root namespace, a refused sysctl is its one problem, goip-parity calls it once before the first row outside any section test, and reports `GOIP_PARITY_EARLY_SETUP_PROBLEMS` without failing | this commit | +| V192 | 2026-10-26 | KG4-T01 | three mutants in scratch worktrees, each through vm-lib-exp | all three caught: the early call dropped (the two goip-parity corner rows fail), the call moved inside the row loop (the before-the-first-row row fails), and a refused sysctl returning no problem (both refused-sysctl rows fail) | this commit | +| V193 | 2026-10-26 | KG4-T01 | `nix build .#checks.x86_64-linux.nix-fmt`, then `.#checks.x86_64-linux.statix`, then `.#checks.x86_64-linux.deadnix` | all three pass | this commit | +| V194 | 2026-10-26 | KG4-T01 | `nix run .#microvm-x86_64-goip-parity -- --timeout 12000 --section link` | 360 of 360 link rows `GOIP_PARITY_PASS`, no `GOIP_PARITY_EARLY_SETUP_PROBLEMS`; the sysctl set before the first row moves no fixture | this commit | +| V195 | 2026-11-01 | KG4-T02 | `go vet ./internal/goip/...`, `go test -count=1 -race ./internal/goip/... ./pkg/xtcpnl/` | all pass: `TestGreCorpusArgv` (45, 11 and 25 argvs, one per row), `TestLinkGreWriteMatchesIp` over the 31, 10 and 19 rows that sent a request, byte for byte with their lookups and prefetch, `TestLinkGreRefusals` (the 21 `ip`-refused corpus rows plus the 13 built rows, `help` for each kind among them), `TestTunnelPrefetch` (11 built replies), `TestParseGreArgs` (17 rows) | this commit | +| V196 | 2026-11-01 | KG4-T02 | mutation, a scratch worktree, each `go test ./internal/goip/...` in a `systemd-run --scope -p MemoryMax=6G` with `-timeout=180s`: tos before ttl in the chain; `external` by prefix; `encap-csum6` as a word; the erspan words on gre; the prefetch skipped; the prefetch on a set with no words; the prefetch keyed on `setOp()`; the prefetch with `IFLA_EXT_MASK`; the missing-`INFO_DATA` check skipped; the endpoint length check skipped; `dev`'s nodev as 1; the numeric key's failure as 1; the dotted key's failure as 255; the multicast check after external; the usage line unpadded | 15 of 15 killed: by `gre/add_abbrev`; `add_strcmp_abbrev`; `add_encap_csum6_word`; both `add_erspan_word` rows; `set_changeable`; `add_no_option` and `set_no_option`; the delete row; `set_changeable` and every prefetch row; `set_not_a_tunnel` and the prefetch-before-words row; the 16-byte row; `add_dev_missing`; `add_key_bad` and the key-past-32-bits row; `add_key_dotted_bad`; the multicast-before-external row; every usage-line row | scratch | +| V197 | 2026-11-01 | KG4-T02 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree holding only the task's diff | both pass; the first lint runs found goconst (`seq`, `nopmtudisc` and `pmtudisc` three times each, counting `obj_l2tp.go`'s and `obj_xfrm.go`'s), so gre's chain spells them `greSeq`, `greNopmtudisc` and `grePmtudisc` | this commit | +| V198 | 2026-11-01 | KG4-T03 | `go vet ./internal/goip/...`, `go test -count=1 -race ./internal/goip/... ./pkg/xtcpnl/` | all pass: `TestGre6CorpusArgv` (36, 9 and 19 argvs, one per row), `TestLinkGre6WriteMatchesIp` over the 26, 9 and 15 rows that sent a request, byte for byte with their lookups and prefetch, `TestLinkGre6Refusals` (the 14 `ip`-refused corpus rows plus 8 built rows), `TestGre6Prefetch` (4 built replies, the IPv4 gre's 4-byte remote among them), `TestParseGre6Args` (16 rows) | this commit | +| V199 | 2026-11-01 | KG4-T03 | mutation, as V196: tclass in base 0; flowlabel in base 0; tos before ttl; `encaplimit` by prefix; the erspan words held to ip6erspan; `ERSPAN_VER` left out at the defaults; a 4-byte seeded remote accepted; `remote ::` kept by `tunnelAddr`; a duparg; dscp taking any value; `pmtudisc` as a word; no flowlabel ceiling; tclass inherit clearing its flag; allow-localremote always set; `fl` by prefix | 14 of 15 killed: by `ip6gre/add_abbrev`; the two hex-flowlabel rows; the `t 5` row; `add_strcmp_abbrev`; `add_erspan_ver_zero`; `add_abbrev`; the IPv4 gre prefetch row; `add_repeat`; the dscp row; the pmtudisc row; `add_flowlabel_over`; `add_flags`; the noallow-localremote row; and `fl` by prefix, after a row `f 10` (usage) was added for it. `remote ::` kept is equivalent: the encoder's `kindAddrSet` drops it too, as `ip`'s `is_addrtype_inet_not_unspec` does | scratch | +| V200 | 2026-11-01 | KG4-T03 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree holding only the task's diff | both pass; the first lint run found goconst (`csum`, `iseq`, `noikey`, `nookey`), the two chains' shared head, so gre and gre6 now build their chains from `greCommonChain` | this commit | +| V201 | 2026-11-01 | KG4-T04 | `go vet ./internal/goip/...`, `go test -count=1 -race ./internal/goip/...` | all pass: `TestIptnlCorpusArgv` (47 and 37 argvs, one per row), `TestLinkIptnlWriteMatchesIp` over the 34 ipip and 26 sit rows that sent a request, byte for byte with their lookups and prefetch, `TestLinkIptnlRefusals` (the 24 `ip`-refused corpus rows plus 5 built rows), `TestIptnlPrefetch` (10 rows: built replies, a 16-byte local and a 4-byte 6rd prefix among them, and the not-implemented words), `TestParseIptnlArgs` (16 rows) | this commit | +| V202 | 2026-11-01 | KG4-T04 | mutation, as V196: the ttl check without the seeded ttl; the check at ttl > 1; `remote` by prefix; isatap on ipip; `mode ip6ip` on ipip; the 6rd words sent on ipip; `get_prefix`'s failure as 255; the `ip4ip4` alias dropped; isatap not sent; `dev` as strcmp; no default pmtudisc; the `any`/`default` prefix check skipped; the 6rd prefix length check loosened; local sent as remote; the seeded pmtudisc ignored | 15 of 15 killed: by `set_nopmtudisc_ttl`; sit's `add_ttl_nopmtudisc` (`ttl 0x1`); `add_remote_abbrev`; `add_isatap`; `add_mode_ip6ip`; `add_6rd_ignored`; `add_6rd_prefix_v4`; `add_mode_alias`; sit's `add_isatap`; `set_dev`; `add_ttl`; a `6rd-prefix default` row, added for it (`any` has no bytes, so the address check refused it already); the 4-byte 6rd prefix row; `add_6rd_ignored`; `set_external_wildcard` | scratch | +| V203 | 2026-11-01 | KG4-T04 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree holding only the task's diff | both pass on the first run | this commit | +| V204 | 2026-11-01 | KG4-T05 | `go vet ./internal/goip/...`, `go test -count=1 -race ./internal/goip/...` | all pass: `TestIp6tnlCorpusArgv` (49 argvs, one per row), `TestLinkIp6tnlWriteMatchesIp` over the 36 rows that sent a request, byte for byte with their lookups and prefetch, `TestLinkIp6tnlRefusals` (the 13 `ip`-refused corpus rows plus 6 built rows), `TestIp6tnlPrefetch` (5 built replies: the encap reset, a seeded fwmark and flag, a 4-byte remote, and a sit device, which the shared prefetch leaves not implemented), `TestParseIp6tnlArgs` (14 rows) | this commit | +| V205 | 2026-11-01 | KG4-T05 | mutation, as V196: `encaplimit` by prefix; tclass in base 0; the bad encaplimit sent as 0; dsfield as strcmp; dscp taking any value; no flowlabel ceiling; fwmark inherit as a number; the `ip4ip6` alias dropped; allow-localremote inverted; the prefetch as AF_INET; `hlim` dropped; local sent as remote; `fl` dropped; `pmtudisc` as a word | 14 of 14 killed: by `add_encaplimit_abbrev`; `add_tclass` and `add_dsfield_abbrev`; the two bad-encaplimit rows (the first try at that mutant didn't compile and was rewritten); `add_dsfield_abbrev`; `add_dscp_bad`; `add_flowlabel_over`; `add_fwmark_inherit`; `add_mode_alias`; `add_allow_localremote`; `set_changeable`; `set_changeable`; `add_default`; `add_flowlabel_inherit`; the pmtudisc row. The dsfield and `hlim` mutants were run again on the shared `tunnelTTLTOSCanon` after V206's change, and both are still killed | scratch | +| V206 | 2026-11-01 | KG4-T05 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree holding only the task's diff | both pass; the first lint run found goconst (`hlim` and `tclass`, three times across gre, ipip and ip6tnl, and `any`, which `addrNameAnyCst` already spells), so they are `tnlKeyHlim`, `tnlKeyTClass` and `addrNameAnyCst` (xfrm_selector.go's `any` too), and ipip's and ip6tnl's ttl and tos aliases are one `tunnelTTLTOSCanon` | this commit | +| V207 | 2026-11-01 | KG4-T06 | `go vet ./internal/goip/...`, `go test -count=1 -race ./internal/goip/...` | all pass: `TestVtiCorpusArgv` (58 argvs, one per row: 23 vti, 12 vti6, 23 xfrm), `TestLinkVtiWriteMatchesIp` over the 16, 9 and 17 rows that sent a request, byte for byte with their lookups and prefetch, `TestLinkVtiRefusals` (the 16 `ip`-refused corpus rows plus 7 built rows), `TestVtiPrefetch` (6 built replies: the seed, the link's low byte, vti6 on a vti device, a dummy, `-6` on vti, and xfrm's set with no prefetch), `TestParseVtiArgs` (10 rows) | this commit | +| V208 | 2026-11-01 | KG4-T06 | mutation, as V196: `fwmark` by prefix; `external` by prefix; the vti prefetch dropped; xfrm given a prefetch; `tnlParseKey`'s name not the canonical word; a multicast remote dropped; xfrm's missarg ahead of external; vti6's remote unmapped; a zero ifindex accepted; `if_id 0` accepted; the conflict without the link; vti6 parsed as AF_INET; fwmark in base 10; the vti6 prefetch as AF_INET | 14 of 14 killed: by `add_fwmark_abbrev`; `add_external_abbrev`; `set_collide`; `set_add_dev`; `add_key_bad`; the multicast row; `add_external`; the v4-mapped row; `add_dev_missing`; `add_if_id_zero`; the dev-before-external row; vti6 `add_default`; `add_fwmark`; vti6 `set_collide`. The `i foo` refusal row was added so the key name is held on an abbreviation too | scratch | +| V209 | 2026-11-01 | KG4-T06 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree holding only the task's diff | both pass; the first lint run found the test file unformatted (gofmt), fixed | this commit | +| V210 | 2026-11-01 | KG4-T07 | `go vet ./internal/goip/... ./pkg/xtcpnl/`, `go test -count=1 -race ./internal/goip/... ./pkg/xtcpnl/` | all pass: `TestLinkShowDetailGreMatchesSidecar` (the 80 accepted rows, 44 gre-family and 36 gre6-family, `-d` and `-d -j` with their gets, plus 19 named rows), `TestGreDataRender` (19 built rows), `TestParseGreInfoData`'s new endpoint-length row. `TestDetailRefusedForPerKindData`'s tunnel `-d -4 addr show` row was a refusal on gre1 and is now a render; `TestAddrShowDetailFamilyMatchesSidecars` holds it to `tunnel/ip_addr_v4_n` line for line | this commit | +| V211 | 2026-11-01 | KG4-T07 | mutation, as V196: ttl `inherit` in JSON; tos as a JSON number; `pmtudisc` printed when on; a key printed without its flag; hwid 0 as `0x0`; gre6's text word as ttl; the CAP bits printed; ip6gre left out of `renderableInfoData`; the endpoint's family check dropped; the dport `auto`; tos `inherit` for 0; erspan_dir ingress past 1; the remote's length always 16; flowlabel under its inherit flag | 14 of 14 killed: by gre `add_default`'s `-d -j`; gre `add_tos_inherit`'s; tunnel `ip_addr_v4_n` and the pmtudisc rows; `ip_addr_v4_n`; erspan `add_dir_abbrev`; ip6gre `add_abbrev`; ip6gre `add_abbrev` (CAP bits); ip6gre `add_abbrev` (`renderableInfoData`); the two wrong-family remote rows; the gue and unnamed-encap rows; gre `add_tos_inherit`; the egress row; `ip_addr_v4_n`; ip6gre `add_flags`. The first CAP-bits mutant only changed `gre6Flag`'s lookup, which is never passed an unknown bit, so it was equivalent and survived; it was rewritten to print every unnamed bit set, and that one is killed | scratch | +| V212 | 2026-11-01 | KG4-T07 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree holding only the task's diff | both pass | this commit | +| V213 | 2026-11-01 | KG4-T08 | `go vet ./internal/goip/... ./pkg/xtcpnl/`, `go test -count=1 -race ./internal/goip/... ./pkg/xtcpnl/`, `go test ./internal/goipparity/...` | all pass: `TestLinkShowDetailIptunMatchesSidecar` (the 102 accepted rows, 26 ipip, 20 sit, 29 ip6tnl, 11 vti, 7 vti6 and 9 xfrm, `-d` and `-d -j` with their gets, plus 15 named rows), `TestIptunDataRender` (22 built rows), the endpoint-length rows in `TestParseIptunInfoData` and `TestParseVtiInfoData`. The 7_1_4 tunnel topology's `-d link show` and `-d addr show` were refusals and now render; `TestLinkShowDetailMatchesSidecar` holds them to `tunnel/ip_link_n` and `tunnel/ip_addr_n` line for line, and `TestDetailRefusedForUnrenderedKind` drives the refusal on hsr and netkit readbacks with ipip as the control | this commit | +| V214 | 2026-11-01 | KG4-T08 | mutation, as V196: endpoints hidden when absent; keys as numbers; a `hoplimit` JSON key on ip6tnl; pmtudisc printed only when on; xfrm's `link` printed; external not stopping xfrm's print; a zero 6rd relay printed; a zero 6rd prefix length printed; ip6tnl given ipip's proto names; mip6 dropped; isatap on any flag; tos `inherit` for 2; a zero vti key printed; the remote's length always 4; xfrm left out of `renderableInfoData` | 15 of 15 killed: by gre's built rows (`tnlEndpoint` is shared); vti `add_abbrev`; ip6tnl `add_allow_localremote`'s `-d -j`; the tunnel topology's sidecars; xfrm `add_abbrev`; xfrm `add_external`; the zero-relay row; the zero-prefix-length row; the tunnel topology's sidecars; the mip6 row; the tunnel topology's sidecars; ipip `add_tos_inherit`; vti `add_default`; the tunnel topology's sidecars; xfrm `add_abbrev`. The tos mutant was run again on `overlayTOS` after V215's change and is still killed, by gre's and ipip's `add_tos_inherit` | scratch | +| V215 | 2026-11-01 | KG4-T08 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree holding only the task's diff | both pass; the first lint run found goconst (`tos inherit `, three times across gre, ipip and vxlan), so gre and ipip call vxlan's `overlayTOS`, which already prints the word | this commit | +| V216 | 2026-11-01 | KG4-T09 | `nix run .#microvm-x86_64-goip-parity -- --timeout 12000 --section link`, the 272 rows ungated | `GOIP_PARITY_SECTION link (632 of 1102 rows)`; `GOIP_PARITY_PASS` on all 632, the 272 among them, the 66 kernel-refused rows too, and `vti/add_no_option` and `vti6/add_no_option` at rc 0; no `FAIL` or `SKIP`; `OVERALL_PASS`, `HYGIENE_PASS`, `UNGATED_CLEAN`, `CONTROL_NOISY 84`, `DRIVER_PASS`, rc 0. The control count was 4 at V187: 55 tunnel `set` rows add 80, each a prefetch reply's IPv6 cacheinfo timestamps or gretap's random `IFLA_ADDRESS` and `IFLA_PERM_ADDRESS`, which differ between `ip`'s two control legs and are suppressed | VM run | +| V217 | 2026-11-01 | KG4-T09 | the same again, the second consecutive `--section link` run, on the same ungated rows | `GOIP_PARITY_SECTION link (632 of 1102 rows)`; `GOIP_PARITY_PASS` on all 632, the 272 among them; no `FAIL` or `SKIP`; `OVERALL_PASS`, `HYGIENE_PASS`, `UNGATED_CLEAN`, `CONTROL_NOISY 84`, `DRIVER_PASS`, rc 0 | VM run | +| V218 | 2026-11-01 | KG4-T09 | the 272 rows added to `gated_commands` and `TestAllowlistCommitted`'s list; `go vet`, `go test -count=1 -race ./internal/goipparity/ ./pkg/nlparity/` | all pass: `TestIpTunnelMatrixRows` (8 subtests: 248 write rows, the corpus's rc 0 and 2 rows; none of the 88 rc 1 and 255 rows; each floor; `gre/set_changeable` 6, `gre/set_dev` and `ipip/set_dev` 8; each setup; unique names, the three respelled; the 24 read rows; all 272 gated), the other kinds' matrix tests over `kindRowKind`'s table, `TestCompareWriteRow` over the 248 rows' own KC5 captures, `TestUngatedSurfaceIsNotVacuous`, `TestAllowlistCommitted` | this commit | +| V219 | 2026-11-01 | KG4-T09 | mutation, as V189: a set's floor as 4; a setup step dropped; a row dropped; an `ip`-refused row added; `xfrm/set_drop_dev` back to its corpus spelling; vt0's floor as 4; a row dropped from `gated_commands` | 7 of 7 killed: by the floor and prefetch rows; the setup row; `TestCommandsCoverAllowlist` and `TestObjectGatingStatus`; `TestUngatedSurfaceIsNotVacuous` and the request-row rows; `TestCommandTable`'s unique-name row; the read row; the gated row and `TestUngatedSurfaceIsNotVacuous` | scratch | +| V220 | 2026-11-01 | KG4-T09 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree holding only the task's diff, before and after gating | both pass, both times | this commit | +| V221 | 2026-11-01 | KG4-T09 | the corpus against the KC5 dump the rows were first listed from | `ip.rc`, `ip.pcap` and `topology` on disk are the source: `sit/add_6rd_reset` has no directory (it crashed `ip`), `sit/set_external_wildcard` and `sit/set_remote_on_wildcard` are rc 255, and `sit/set_remote_on_inherit`, rc 2, is a row; the twelve kinds total 248 rows at rc 0 or 2 (66 at 2) and 88 at 1 or 255, as the plan says | this commit | +| DOC020 | 2026-11-02 | KG4 close | KG5 broken into KG5-T01 (the kinds with no parser through dummy's else arm, and `iplink_parse`'s `vrf` and `numtxqueues`/`numrxqueues`), KG5-T02 (the hsr words), KG5-T03 (netkit's), KG5-T04 (vrf's, vxcan's and batadv's), KG5-T05 (the bond parsers' 255s), KG5-T06 (hsr's and netkit's `-d` renders) and KG5-T07 (the 144 matrix rows) with their tables. Argued from `iplink_hsr.c`, `iplink_netkit.c`, `iplink_vrf.c`, `iplink_vxcan.c`, `iplink_batadv.c`, `iplink_bond.c`, `iplink_bond_slave.c` and `iplink.c` at `v7.2.0`, from KC6's 181 rows (`ip.rc`, `ip.err`, `ip.out`, `topology`, record counts, readbacks) and KP5's codec, and from a throwaway replay of the 122 request rows and 59 refusals through goip (`replayKindRows` and `corpusRefusals` with `useLLMap720`, then deleted): 54 requests and 3 refusals already match, and every accepted row's `-d` but hsr's 10 and netkit's 19 renders. doccheck | tasks one to one, no broken link or anchor | this commit | +| V222 | 2026-11-02 | KG4 close | `nix run .#microvm-x86_64-goip-parity -- --timeout 20000`, the full table, before the push of KG4 and DOC020 | `GOIP_PARITY_PASS` on 1102 of 1102 rows; `OVERALL_PASS`, `HYGIENE_PASS`, `UNGATED_CLEAN`, `CONTROL_NOISY 1147`, `DRIVER_PASS`, rc 0 | VM run | +| V223 | 2026-11-02 | KG5-T01 | `go vet ./internal/goip/...`, `go test -count=1 -race ./internal/goip/...`, `go test ./internal/goipparity/...` | all pass: `TestNoParserCorpusArgv` (56 argvs, one per row: 9 ifb, 6 nlmon, 13 vcan, 14 team, 4 team_slave, 10 vrf_slave), `TestLinkNoParserWriteMatchesIp` over the 5, 5, 11, 11, 3 and 7 rows that sent a request (15 at rc 2), byte for byte with their lookups, `vrf_slave/set_vrf`'s vr0 get with mask 0x109 and by name with none among them, `TestLinkNoParserRefusals` (the 12 `ip`-refused corpus rows plus 6 built: vrf naming no device, `numtxqueues x`, `numrxqueues 4294967296`, a second count, `type` read as the count, and no word after the keyword), `TestLinkNoParserHelp` (2), `TestLinkQueueCounts` (6: both in word order, hex, 2147483647, -1 as 0xffffffff, -1 then a second count not a duplicate, `numrx`). `TestWsGoipDifferential`'s golden file rewritten (`-update-ws-o5`): `linkadd` 43 same and 68 refused becomes 111 same; `linkattr/ng-002` (`vrf br0`, 255) now matches, and the `vrf v0` rows refuse at their next unimplemented keyword | this commit | +| V224 | 2026-11-02 | KG5-T01 | mutation, as V196, `-run 'NoParser\|LinkQueueCounts'` in a scratch worktree under `systemd-run --user --scope -p MemoryMax=6G`, `-timeout=180s`: vrf_slave out of the set; `vrf` without `IsVRF`; `IsVRF` before the resolver; `numrxqueues` as strcmp; a negative count refused; a second count accepted; Garbage at 255 | 7 of 7 killed: by `vrf_slave/set_type_word`; `set_vrf_not_vrf`; vrf naming no device; `numrx`; -1; the second count; the Garbage rows | this commit | +| V225 | 2026-11-02 | KG5-T01 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree holding only the task's diff | both pass; the first lint run found an `appendAssign` in `TestLinkQueueCounts` (gocritic), fixed with `slices.Clone` | this commit | +| V226 | 2026-11-02 | KG5-T02 | `go vet ./internal/goip/...`, `go test -count=1 -race ./internal/goip/... ./internal/goipparity/...` | all pass: `TestHsrCorpusArgv` (29 argvs, one per row), `TestLinkHsrWriteMatchesIp` over the 20 rows that sent a request (10 at rc 2, `add_version_2` and `add_proto_2` among them), byte for byte with the slaves' and the interlink's gets, `TestLinkHsrRefusals` (the 9 `ip`-refused corpus rows plus 6 built: version and proto 256, supervision -1, `inter`, `h`, a missing interlink). A built row naming d0 then nosuch was dropped: no capture holds both replies, and `add_slave_missing` covers the missing slave | this commit | +| V227 | 2026-11-02 | KG5-T02 | mutation, as V224, `-run Hsr`: supervision after slave1 in the chain; interlink by prefix; version checked as a value; the repeat dropped; supervision in base 10; the slave get skipped on a bad name | 6 of 6 killed: by `add_abbrev`; `add_interlink_abbrev` and `inter`; `add_version_2`; `add_supervision_twice`; `add_supervision_hex`; `add_slave_missing` and the missing interlink | this commit | +| V228 | 2026-11-02 | KG5-T02 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree holding only the task's diff | both pass; the first lint run found `"proto"` a third time (goconst, tier 2), fixed by reusing `xfrmProtoCst` | this commit | +| V229 | 2026-11-02 | KG5-T03 | `go vet ./internal/goip/...`, `go test -count=1 -race ./internal/goip/... ./internal/goipparity/...` | all pass: `TestNetkitCorpusArgv` (43 argvs, one per row), `TestLinkNetkitWriteMatchesIp` over the 27 rows that sent a request (8 at rc 2), byte for byte with their lookups, `TestLinkNetkitRefusals` (the 15 `ip`-refused corpus rows plus 4 built: a second peer mid-line, the word after the peer's kind, a second mode after peer, scrub after peer with no value), `TestLinkNetkitBuilt` (2: the device's `up` kept off the peer, help in the peer not implemented); 48 subtests | this commit | +| V230 | 2026-11-02 | KG5-T03 | mutation, as V224, `-run Netkit`: mode by prefix; `PEER_POLICY` before peer; the device's flags in the peer; the peer's flags dropped; a second peer accepted; `(null)` as the empty string; the peer nest for peer alone | 7 of 7 killed: by `add_mode_abbrev`; `add_policy_twice`; `add_peer_name` and the header row; `add_peer_up`; `add_peer_twice`; `add_peer_twice` and `add_peer_type`; `add_peer_only` | this commit | +| V231 | 2026-11-02 | KG5-T03 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree holding only the task's diff | both pass; the first lint run found `"default"` a third time (goconst, tier 2), fixed by reusing `addrDefaultGroupCst` | this commit | +| V232 | 2026-11-02 | KG5-T04 | `go vet ./internal/goip/...`, `go test -count=1 -race ./internal/goip/... ./internal/goipparity/...` | all pass: `TestVrfVxcanBatadvCorpusArgv` (53 argvs, one per row: 18 vrf, 18 vxcan, 17 batadv), `TestLinkVrfVxcanBatadvWriteMatchesIp` over the 12, 9 and 12 rows that sent a request (13 at rc 2), byte for byte with their lookups, `TestLinkVrfVxcanBatadvRefusals` (the 19 `ip`-refused corpus rows, vxcan's three usage rows held to `ip.out`'s first line, plus 4 built: vxcan's duparg naming the kind, `table blue`, `tbl`, veth's peer `type` at 255), `TestLinkVxcanPeerHelp` (1); 57 subtests | this commit | +| V233 | 2026-11-02 | KG5-T04 | mutation, as V224, `-run 'VrfVxcanBatadv\|VxcanPeerHelp'`: table as strcmp; table names refused; -1 accepted; vxcan's peer by prefix; veth's text on vxcan; `ALGO_NAME` without its NUL (in KP5's encoder); ra as strcmp; veth's duparg at 1 | 8 of 8 killed: by `vrf/add_abbrev`; `add_table_main`; `add_table_neg`; `vxcan/add_abbrev`; vxcan's three usage rows; `batadv/add_abbrev` and the other ra rows; `batadv/add_abbrev`; `add_peer_type` and the veth row | this commit | +| V234 | 2026-11-02 | KG5-T04 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree holding only the task's diff | both pass first time | this commit | +| V235 | 2026-11-02 | KG5-T05 | `go vet ./internal/goip/...`, `go test -count=1 -race ./internal/goip/... ./internal/goipparity/...` | all pass: `TestEncodeBondOptions` and `TestEncodeBondSlaveOptions` with a status on every refusal row (25 existing, two of which, `ad_actor_sys_prio 65536` and `use_carrier 256`, are `invarg`s at 255, plus 6 added: `mode foo`, `miimon x`, `primary nosuch`, `coupled_control maybe`, `arp_ip_target 10.0.0.x`, slave `queue_id x`), `TestCIntBits` (15), `TestLinkIfindexBits` (4: a primary, 2147483647, -1 as 0xffffffff, a master of -1) | this commit | +| V236 | 2026-11-02 | KG5-T05 | mutation, as V224, `-run 'TestEncodeBond\|TestCIntBits\|TestLinkIfindexBits'`: invarg back to 1; the incomplete line back to 1; the unknown word at 255; `s32Bits`' negative branch dropped; `u32AsS32`'s negative branch dropped; `low32` without the mask | 6 of 6 killed: by `mode 7` and the other invargs; both missing-value rows; `lacp_act` and the other unknown words; the -1 rows; the 0xffffffff row; the low-bits row | this commit | +| V237 | 2026-11-02 | KG5-T05 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree holding only the task's diff; `grep -n nolint internal/goip/link_bond_args.go internal/goip/link_write.go` | both pass first time; the grep finds nothing | this commit | +| V238 | 2026-11-02 | KG5-T06 | `go vet ./internal/goip/...`, `go test -count=1 -race ./internal/goip/... ./internal/goipparity/...` | all pass: `TestLinkShowDetailHsrNetkitMatchesSidecar` over the 10 hsr and 19 netkit rows at rc 0, `-d` line for line and `-d -j` entry for entry, with the capture's gets (the slaves' and the interlink's, sent by `resolveLinkRefs`), plus 5 rows (the slaves by name, no `-d`, hsr's and netkit's JSON keys, `type primary`); `TestHsrNetkitDataRender` (11 built rows); `TestDetailRefusedForUnrenderedKind` rewritten over built links (7: wireguard's data, a slave kind with no print_opt, hsr, netkit and ipip render, no data, no `-d`) | this commit | +| V239 | 2026-11-02 | KG5-T06 | mutation, as V224, `-run 'HsrNetkit\|DetailRefused'` over `./internal/goip/` and `./internal/goip/render/`: `` as an empty string in JSON; the short-payload return dropped; `sequence` as the JSON key; UNKNOWN dropped; the slaves' gets dropped; primary and peer swapped | 6 of 6 killed: by the no-slaves rows; the short row; every hsr `-d -j` row; the unknown-mode rows; every hsr `-d` row; every netkit row | this commit | +| V240 | 2026-11-02 | KG5-T06 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree holding only the task's diff | both pass first time | this commit | +| V241 | 2026-11-02 | KG5-T07 | `nix run .#microvm-x86_64-goip-parity -- --timeout 12000 --section link`, the 144 rows ungated | `GOIP_PARITY_SECTION link (776 of 1246 rows)`; `GOIP_PARITY_PASS` on all 776, the 144 among them, the kernel-refused rows and each kind's first row (its module's first load) too; no `FAIL` or `SKIP`; `OVERALL_PASS`, `HYGIENE_PASS`, `UNGATED_CLEAN`, `CONTROL_NOISY 86`, `DRIVER_PASS`, rc 0. The control count was 84 at V217 | VM run | +| V242 | 2026-11-02 | KG5-T07 | the same again, the second consecutive `--section link` run, on the same ungated rows | `GOIP_PARITY_SECTION link (776 of 1246 rows)`; `GOIP_PARITY_PASS` on all 776, the 144 among them; no `FAIL` or `SKIP`; `OVERALL_PASS`, `HYGIENE_PASS`, `UNGATED_CLEAN`, `CONTROL_NOISY 87`, `DRIVER_PASS`, rc 0 | VM run | +| V243 | 2026-11-02 | KG5-T07 | the 144 rows added to `gated_commands` and `TestAllowlistCommitted`'s list; `go vet`, `go test -count=1 -race ./internal/goipparity/... ./pkg/nlparity/...` | all pass: `TestRemainingKindsMatrixRows` (8 subtests: 122 write rows, the corpus's rc 0 and 2 rows; none of the 59 rc 1 and 255 rows; each floor; the floors that count lookups, `vrf_slave/set_vrf` 8, `hsr/add_interlink` 8 and `netkit/set_peer_name` 6; each setup; unique names, the six respelled; the 22 read rows; all 144 gated), `TestCompareWriteRow` over the 122 rows' own KC6 captures, `TestUngatedSurfaceIsNotVacuous`, `TestAllowlistCommitted`. The first run found `TestParityCannotOpenASocket` failing on 78ae084b, which put `pkg/cint` in goip-parity's closure without allowlisting it; fixed in this commit | this commit | +| V244 | 2026-11-02 | KG5-T07 | mutation, as V219, in a scratch worktree under `systemd-run --user --scope -p MemoryMax=6G`, `-timeout=180s`: `vrf_slave/set_vrf`'s floor as 6; a setup step dropped; a row dropped; an `ip`-refused row added; `vcan/set_mtu_up` back to its corpus spelling; hsr's read floor short by 2; a row dropped from `gated_commands` | 7 of 7 killed: by the lookup-floor row; the setup row; the request-row row; the negative row; the unique-name row; the read row; the gated row, `TestUngatedSurfaceIsNotVacuous` and `TestAllowlistCommitted` | scratch | +| V245 | 2026-11-02 | KG5-T07 | `nix build .#checks.x86_64-linux.lint-baseline`, then `.#checks.x86_64-linux.netlink-audit`, in a scratch worktree at d268aee6 holding only the task's diff, gated | both pass first time | this commit | +| V246 | 2026-11-02 | KG5 close | `nix run .#microvm-x86_64-goip-parity -- --timeout 20000`, the full table, in a clean worktree at 3a8bc8e3, before the push of KG5 | `GOIP_PARITY_PASS` on 1246 of 1246 rows; no `FAIL` or `SKIP`; `OVERALL_PASS`, `HYGIENE_PASS`, `UNGATED_CLEAN`, `CONTROL_NOISY 1149`, `DRIVER_PASS`, rc 0 | VM run | +| DOC021 | 2026-11-02 | KG5 close | KG5 and the plan closed: [Where goip stands](goip-link-kinds.md#where-goip-stands) rewritten for what KG1 to KG5 landed (each kind's args and render file; the two refusals now reach only out-of-scope kinds), the plan's status and this summary updated. No next phase to expand. doccheck | tasks one to one, no broken link or anchor | this commit | + +## Decision log + +**2026-10-24:** +- **Kinds that need hardware or another creation path are out.** can, ipoib, dsa, rmnet, wwan and virt_wifi each need a lower device or hardware the VM doesn't have; netdevsim is created through sysfs, not rtnetlink. vcan, which is in, takes none of `iplink_can.c`'s options. +- **XDP is class F.** `iplink_xdp.c`'s `-d` output makes a `bpf()` syscall per program, which nlmon can't see. `iplink_xstats.c` isn't a kind; `link xstats` is a G1 read already. +- **maddress stays class F at the 7.2.0 pin.** `RTM_GETMULTICAST` reached `ip/ipmaddr.c` in `b8cdda55`, after the tag. [goip-parity-gaps.md](goip-parity-gaps.md#f-not-netlink-outside-pcap-parity) said "the 7.2 checkout" and is corrected. It is revisited at the next pin bump. +- **No fallback tunnels after late setup.** `late_setup` sets `net.core.fb_tunnels_only_for_init_net=2` before it loads the tunnel modules, so the namespaces the link-write driver builds afterwards get no `tunl0`, `gre0`, `sit0`, `ip6tnl0` or the like, and no later fixture's ifindexes move. The dump driver's tunnel set keeps its fallback devices: it loads its modules before `late_setup`, and they are half its coverage. +- **Kind modules load in `late_setup`,** not on demand through `rtnl-link-`, so the load happens at one known point after every existing row. +- **The guest kernel is 7.3.0** (`linuxPackages_latest`). The 7.3.0 dump corpus (`testdata/7_3_0/dumps`) is the reference for the dump driver's comparisons; the link-write driver is compared with a run of the code before the task, since its committed blobs are older. +- **gtp is refused by the guest kernel** (`late/kinds`: `RTNETLINK answers: Invalid argument`). `ip` 7.2.0 always asks for `IFLA_GTP_CREATE_SOCKETS`; KC4 finds the attribute the kernel wants, or moves gtp out of scope with the reason. +- **`genl/mptcp_limits_set_over` fails on kernel 7.3, before this plan.** Kernel `c8646664fbf1` ("mptcp: pm: in-kernel: increase all limits to 64") raised the limit from 8 to 64, so `ip mptcp limits set subflows 9` is accepted. The link-write driver at `34724ad7`, before KC0, fails on the same row, so it is not KC0's. It is fixed outside this plan, in the corpus row, its hermetic check and the goip and goipparity rows that copy the limit; until then the link-write driver's `NLCAP_DRIVER_FAIL` on that one row is expected. +- **An `ip` refusal before netlink exits with 1 or 255.** `invarg`, `incomplete_command` and `duparg2` call `exit(-1)`, so 255; a parse error returned up the stack instead (`parse_on_off`'s) reaches `do_cmd`, which exits with the negated return, so 1. KC1-T01's plan assumed 255 for both, and its first VM run corrected `inet_bad_on_off` to 1. Every corpus now admits both, and KG0 matches the status as well as the text. +- **bridge's `ip` refusals exit 1 where `bridge_parse_opt` returns,** as the KC1 entry above has it: an abbreviated `strcmp` keyword (`stp_m`) is an unknown word, and `parse_one_of` returns on a name it doesn't know (`stp_mode bogus`). KC2-T01's plan said 255 for both; its first run (V012) corrected them. +- **bridge's JSON keys aren't all its keywords.** `group_address` prints as `group_addr`, the `*_count` options as `*_cnt` and the `*_interval` ones as `*_intvl`; `group_fwd_mask` prints in hex. KG1's renderer follows the readbacks, not the keyword table. +- **A port's `state` reads back what the bridge makes of it.** With STP off, `br_set_port_state` sets the state sent and then runs `br_port_state_selection`, which moves an enabled port to forwarding, so `set_state`'s `state 4` reads back `forwarding`. The request pins the 4; KG1 renders what the kernel reports, as `ip` does. +- **An `ip` refusal can still send datagrams.** `backup_port` resolves its name with an `RTM_GETLINK` before `invarg` exits, so `set_backup_port_missing` has 4 datagrams at floor 0. A floor is a minimum, and the row's evidence is its `ip.err`. +- **The VM namespace's rp_filter is 2,** not the kernel's 0, so `inet_rp_filter_max` writes the value it reads back. The row still pins the request; `inet_several` (`rp_filter 1`) shows a change. +- **gtp is in scope; KC0's probe sent no option.** With nothing after `type gtp`, `iplink_parse` doesn't call `gtp_parse_opt`, no `INFO_DATA` goes out, and `gtp_validate` refuses `!data`. `role ggsn` is enough: `ip` then sends `IFLA_GTP_CREATE_SOCKETS`, and the kernel opens its own sockets. KC4-T04 has the rows; the KC0 entry above is answered. +- **7.2.0's geneve option is `local`, not `local` and `local6`.** The plan named both; `iplink_geneve.c` has one `strcmp` keyword that sends `IFLA_GENEVE_LOCAL` or `IFLA_GENEVE_LOCAL6` by the address's family, and `local6`, like `remote6`, is only an output key. KC4-T02 has a row for each family and one for the word `local6`, which `ip` refuses. +- **`get_addr` exits 1.** A bad or wrong-family address calls `exit(1)`, not `exit(-1)`, so an address `ip` can't parse reads like a returned error. KC4's tables use it; KC3's rows had no address. +- **KC5's encap rows: fou is loaded, fou6 isn't.** `late_modules` already loads `fou` for its genl family, and it registers the IPv4 FOU and GUE encap ops, so gre's, ipip's and sit's encap rows are accepted. `fou6` isn't loaded, so the ip6 kinds' `encap fou`/`gue` rows are kernel refusals (`-EINVAL`). Their requests still carry every `ENCAP_*` attribute, which is what KP and KG need; loading `fou6` would add readbacks, not request coverage. +- **The guest kernel has no `CONFIG_IPV6_SIT_6RD`.** sit's 6rd words are sent and ignored, so its 6rd rows are accepted and read back nothing; `sit/add_6rd_overlong` (48 + 24 bits, which a 6rd kernel refuses) is the canary if the config changes. +- **KC5 is five tasks, one per iproute2 parser or pair.** The drafts were argued from `link_gre.c`, `link_gre6.c`, `link_iptnl.c`, `link_ip6tnl.c`, `link_vti.c`, `link_vti6.c` and `link_xfrm.c` at `v7.2.0` and the 7.3 kernel's checks. Two findings shape every task: a bare `type ` is accepted, and every parser but xfrm's fetches the device before a set and resends its current values. +- **sit's `6rd-reset` has no row: it segfaults `ip` 7.2.0.** `link_iptnl.c:288` passes the string literal `"2002::/16"` to `get_prefix`, whose `get_prefix_1` writes a NUL over the `/` (`lib/utils.c:693-695`), so `ip` faults on read-only memory before it sends anything. KC5-T03's first VM run found it (V046). KG5 decides whether goip refuses the word or sends what it was meant to, `2002::/16` with no relay prefix; parity can't compare a crash. Nothing is sent upstream. +- **A set on sit's wildcard exits 255 before netlink.** The kernel gives a sit made with no `INFO_DATA` `ttl 64` and no DF (`ipip6_netlink_parms`), the set's prefetch seeds both, and `link_iptnl.c` refuses `ttl` nonzero with `nopmtudisc`. ipip's wildcard has `ttl` 0, so its sets reach the kernel. KC5-T03 keeps the two 255 rows and reaches the kernel's point-to-point check through a `ttl inherit` wildcard. +- **KC6 is four tasks:** hsr; netkit; vrf, vrf_slave and vxcan; batadv with the five kinds `ip` has no parser for. The drafts were argued from `iplink_hsr.c`, `iplink_netkit.c`, `iplink_vrf.c`, `iplink_vxcan.c`, `iplink_batadv.c`, `iplink_ifb.c`, `iplink_nlmon.c`, `iplink_vcan.c` and `iplink_team.c` at `v7.2.0` and the 7.3 kernel's checks. Three findings shape every task: + - `iplink.c`'s `nodev` returns rather than exits, so a set on a missing device is 1. The tunnel parsers' `exit(nodev(…))` was 255. + - A set of only `type K` sends no `INFO_DATA` and reaches no kind code, so it is 0 for any kind. + - A kind with no `parse_opt` takes no word after `type`. Each word is Garbage (1), or `usage()` (255) for a prefix of `help`. +- **hsr's `version` and `proto` checks in `ip` 7.2.0 test `get_u8`'s return, not the value** (`iplink_hsr.c:64-66`, `:71-73`). Any u8 passes, and the kernel refuses 2 and above. KC6-T01 pins it with `add_version_2` and `add_proto_2`; KG5 decides whether goip copies the check as written. Nothing is sent upstream. +- **netkit's rows never run under `ip -batch`.** `netkit_parse_opt` keeps its state in statics, so a second row in one batch would inherit the first's. +- **vxcan's usage goes to stdout.** `iplink_vxcan.c`'s `print_usage` ignores its `FILE *`, so `add_unknown`'s usage is in `ip.out`, not `ip.err`. +- **batadv has one attribute, not two.** The inventory said 2; `IFLA_BATADV_ALGO_NAME` is the only member after `UNSPEC`. +- **KC7 commits a 7.3.0 link-write corpus.** The kind captures lived only in the scratch runs the V### rows compared, and the committed link-write corpus is 7.1.4's, older than the kind corpus and the guest kernel. KC7-T01 commits one run's output at the runner's default, `testdata/7_3_0/link-write`, and leaves `7_1_4/` and the tests that name it alone. `genl/mptcp_limits_set_over` lands there with the kernel's 0 (V004). +- **`corpusAll` skips the 7.3.0 link-write set.** The 17 corpus tests that tally every message under `testdata` count one capture of each row; the 7.3.0 set recaptures the 7.1.4 set's rows, so reading both doubles them. The kind tests read `tdLinkWrite_7_3_0` directly, and `TestCorpusAllSkipsLinkWrite730` pins the skip. +- **7.2.0 names 46 link kinds, not 48.** The plan's count was wrong: `ip/iplink_*.c` and `ip/link_*.c` at `v7.2.0` hold 46 `link_util` ids. `macsec` (`ipmacsec.c`) and `tun` (`iptuntap.c`) are `link_util`s outside those files and outside the plan. With the 7 exclusions, 39 kinds are in scope. +- **bond, bond_slave, dummy and veth are captured through shared readbacks.** Their `write/` rows predate `link-write-readback.exp` and have no readbacks of their own. The gate pairs each kind's accepted `write/` rows with a `-d -j` dump in the same corpus that shows a device of the kind: `lacp_converged/ip_link_n_json` for bond, bond_slave and veth, and `kinds/vrf_slave/set_master/ip_getlink_detail_json`'s `d0` for dummy. +- **The `inet` request and dump don't share a shape.** The request's `IFLA_INET_CONF` carries `NLA_F_NESTED` and holds u32 attributes typed by devconf id; the dump's is a raw array of `IPV4_DEVCONF_MAX` u32s with no flag. KP0's codec keeps two decoders. The encoder refuses an empty list, since `ip` never sends one, and ids outside 1 to 33, but not `mc_forwarding` (2): the kernel's policy refuses that. +- **The bridge's `vlan_protocol` is big-endian on the wire.** `ll_proto_a2n` returns the protocol byte-swapped and `addattr16` copies it, so KP1's parser reads it in network order. +- **The bridge's keyword `ageing_time` is one constant.** misspell reads string literals and upper-case tokens, and the keyword must stay the kernel's spelling. `BridgeKeywordAgeingTime` assembles it from two halves and `IflaBrAgeingTime` is the enum's 4, so no lint exclusion is needed; goip reads both from `xtcpnl`. +- **A kind's readback agreement uses the JSON readback's own pcap.** The text and JSON readbacks are two `ip` runs, and timers move between them (`gc_timer` 148.48 against 148.49). KP1-T02 and every later KP task compares a `-d -j` readback with the dump in `netlink_route_getlink_detail_json.pcap`. +- **A new `LinkInfo` field regenerates `pkg/linkmonitor`'s replay goldens.** Their digest hashes the decoded links as JSON, so `Bridge` (and each later kind's field) changes it on every link. The value counts are the check that nothing else moved. +- **Port attributes `ip` never prints still decode.** The kernel dumps the MRP and EHT counters and `mcast_n_groups`/`mcast_max_groups` on every port; `BridgeSlaveOptions` holds them as read-only rows with no keyword, so `Unknown` lists only ids past 45. +- **Option keywords shared across tables are package constants.** goconst counts across `pkg/xtcpnl`, so a keyword two kinds' tables spell (bridge and bridge_slave's `priority`, `group_fwd_mask`, `mcast_router`; the port's and devconf's `proxy_arp`) is one constant rather than a lint exclusion. Later kinds reuse them. +- **The plan's vlan flag mask was wrong.** `gvrp on mvrp on` is flags and mask 0xa, not 0x6: `VLAN_FLAG_MVRP` is 0x8 and `LOOSE_BINDING` 0x4, and `vlan_print_flags` prints MVRP first. `VlanFlags` keeps the print order. +- **The plan's macvlan macaddr ops were wrong.** `enum macvlan_macaddr_mode` is add 0, del 1, flush 2, set 3; the plan had flush and set swapped. The codec uses the kernel's values. +- **vxlan's `ageing` is one constant too.** As with the bridge's `ageing_time`, `VxlanKeywordAgeing` assembles the keyword and JSON key, `IflaVxlanAgeing` is the enum's value (8), and the Go field is `Aging`. +- **The kernel never dumps `IFLA_VXLAN_MC_ROUTE`, and dumps `VNIFILTER` only when on.** `add_mcroute` is accepted, but its readback has no `mcroute`; only `add_vnifilter`'s has `vnifilter`. The parser decodes both when sent, and the readback test compares what the dump carries. +- **vxlan's `tos` value is hex.** `rtnl_dsfield_a2n` reads it with `strtoul(…, 16)`, so `tos 10` is 0x10, as `add_tos`'s readback prints it. +- **The vxlan encoder refuses what `vxlan_parse_opt` refuses.** `ErrKindSettingConflict` covers group and remote together, a local of the other family, vnifilter without external, external with an id, no id on an add, and a group without dev; `ErrKindSettingRange` covers a value out of range and a group, remote or local of the wrong cast. The default group `ip -4` and `-6` send when no address is given is not modeled; no row uses it. +- **The plan's group/remote mutant moved to the encoder.** The parser keeps one `Group` address and makes no choice; the group-or-remote key is the render's (KG3), so the mutant inverted the encoder's multicast check instead. +- **The plan's geneve order was wrong for two attributes.** `geneve_parse_opt` sends `TTL_INHERIT` and `DF` inside its loop, in argument order, ahead of `ID`; only the rest waits for the loop to end. `ttl inherit` also marks the ttl given, so a set with it sends `TTL` 0 after the loop. A corner row pins it. +- **The kernel dumps geneve attributes `ip` never prints.** `geneve_fill_info` sends `PORT_RANGE` on every device and `GRO_HINT` when on; the parser decodes both, and the readback test compares only what `ip` prints. +- **The kinds' wire helpers are shared.** The tolerant reads, range-checked writes, the 24-bit VNI and flow-label bounds, and `ErrKindSettingConflict` (vxlan's conflict error, renamed when geneve needed the same) live in `xtcpnl_kind_wire.go`. +- **The plan's bareudp order was wrong.** `bareudp_parse_opt` sends nothing inside its loop: after it, `PORT` and `ETHERTYPE` always, `SRCPORT_MIN` when given and `MULTIPROTO_MODE` when on, whatever order the words came in. The plan said argument order; a corner row pins the real one. +- **A missing word has its own error.** `ErrKindSettingMissing` is a required word not given (vxlan's and geneve's id, geneve's remote on an add, bareudp's dstport and ethertype); `ErrKindSettingConflict` keeps the words that clash. vxlan's and geneve's missing-id cases moved to it, so KG3 can map each to `ip`'s own text (`missarg` against a conflict message). +- **amt's kernel checks stay the kernel's.** A link, a mode and a local on an add, and a discovery for a gateway, are `amt_validate`'s refusals, not `ip`'s, so `EncodeAmtInfoData` sends what it's given, as `add_no_link` and `add_gateway_no_discovery` do. It refuses what `amt_parse_opt` does: a second `local` or `discovery`, a mode with no name, and a port too wide. +- **The plan's amt lower mutant moved to the parser.** Printing the lower as a number is the KG render's; the KP3-T03 mutant drops `LINK`'s ifindex instead. +- **gtp prints `unknown` for a role past sgsn.** The plan said the render has no name; `gtp_role_to_string` returns `unknown`. The parser also skips `LOCAL` and `LOCAL6` with the request-only `FD0`, `FD1` and `CREATE_SOCKETS`; the kernel dumps none of them. +- **"Where goip stands" lagged.** KP1's and KP2's closes didn't add their kinds to it; KP3's close adds all three phases' rows. +- **The tunnel encoders take the device, not a bool.** A set's request is the prefetch's values plus the words, so `EncodeInfoData` takes the decoded `cur`, nil on an add, and the tests decode it from the reply in `ip.pcap`. +- **The prefetch's two `ip` bugs are copied, not fixed.** gre, gre6, iptnl and ip6tnl re-send an unnamed encap port byte-swapped (`ip6gre/set_encap_sport_swap`); vti and vti6 seed `LINK` with `rta_getattr_u8`, keeping only the low byte of the ifindex. The encoders match `ip` byte for byte, and goip copies them too (DOC019; KG4-T02's swapped-port prefetch row holds gre's). Nothing is sent upstream. +- **One gre encoder covers gre, gretap and erspan.** `link_gre.c` parses all three; `EncodeGreInfoData` takes an `erspan` bool for the erspan-only words and the `ERSPAN_VER` default, as `gre_parse_opt` keys them on the kind name. +- **KP4-T01's encode table gains a row.** No corpus row gives an index at version 2, so the plan's third mutant survived its table; `corner: an index at version 2 is not sent` is built to kill it. +- **A malformed endpoint is dropped, not marked.** `tnl_print_endpoint` prints `unknown` for a `LOCAL` or `REMOTE` that isn't 4 or 16 bytes; `ParseGreInfoData` keeps the zero address, which renders `any`, so KP4-T01's short-remote row expects `any`. The kernel always sends 4 or 16 bytes. A 16-byte address on gre, or a 4-byte one on an ip6 kind, still renders `unknown`. +- **The ip6 gre kinds share gre's setting type.** `EncodeGre6InfoData` takes `GreSetting`, with `EncapLimit`, `TClass`, `FlowLabel` and `TnlFlag` added; each encoder refuses the other's options with `ErrKindSettingOption`. It takes no kind: `link_gre6.c` parses the erspan words and sends `ERSPAN_VER` for all three kinds. +- **KP4-T02's tables grew.** Two built corner rows stand beside the corpus rows the plan named (the swapped sport, the `CAP` bits), so each check names its bytes. Four rows were added: an IPv4 remote, gre's pmtudisc, `external`, and `fwmark inherit`. +- **ipip's default proto is any.** `iptunnel_parse_opt` sends `PROTO` 0 unless a mode word is given, and `ipip_tunnel_validate` keeps it, so a plain ipip reads back `"proto": "any"`; the plan's first parse row said `ipip`. +- **isatap on ipip is an option error.** `ip` falls through to its help for `isatap` on ipip (rc 1); `EncodeIptunInfoData` refuses it with `ErrKindSettingOption`, as `EncodeGreInfoData` refuses erspan words on gre. `mode ip6ip` on ipip is `invarg`'s range error, `ErrKindSettingRange`. +- **`IptunInfo` is shared with ip6tnl.** It carries ip6tnl's `ENCAP_LIMIT` and `FLOWINFO`, and reads `FLAGS` at the width the kernel sent: 2 bytes for sit, 4 for ip6tnl. KP4-T04 adds ip6tnl's encoder. +- **ip6tnl doesn't seed the encap.** The entry above that lists ip6tnl among the prefetch's byte-swapped ports is wrong for it: `ip6tunnel_parse_opt` reads no `ENCAP_*` from the prefetch, so a set resets the encap to type 0, `CSUM6` and ports 0, and there is no port to swap. gre, gre6 and iptnl still swap one. +- **The ip6 tunnels share their flag state.** The encap limit, flow info, `IP6_TNL_F_*` flags and fwmark, and the words that tie them together (`encaplimit none`, the `inherit` forms), are `ip6Tnl`, embedded by `EncodeGre6InfoData`'s and `EncodeIp6tnlInfoData`'s encoders; `EncodeIp6tnlInfoData` takes `IptunSetting`, with the four ip6 options added, and refuses ipip's and sit's tos, pmtudisc, isatap and 6rd words with `ErrKindSettingOption`. +- **KP4-T04's tables grew.** The inherit-flags parse row is three rows, one per flag; the encode table adds the mplsip mode and sit's isatap as negatives and `no settings` as a boundary, and its set-resets-the-encap row is built (a `cur` with a fou encap) so the reset is visible in the bytes. +- **The xfrm interface's names carry `Link`.** `xtcpnl_xfrm.go` and `XfrmInfo` are the NETLINK_XFRM family's (an SAD info message), so the kind's decoder is `XfrmLinkInfo` and `ParseXfrmLinkInfoData` in `xtcpnl_xfrm_link.go`, with `EncodeXfrmLinkInfoData`. `LinkInfo.Xfrm` keeps the short name. +- **The vti encoder takes the family.** `vti_parse_opt` and `vti6_parse_opt` differ only in it, and an endpoint of the other family is `ip`'s refusal, so `EncodeVtiInfoData` takes a `v6` bool, as `EncodeIptunInfoData` takes `sit`. The plan's signature had none. +- **An xfrm if_id of 0 is a range error.** `xfrm_parse_opt` refuses it with `invarg` inside the loop, so `EncodeXfrmLinkInfoData` returns `ErrKindSettingRange` for it; `ErrKindSettingMissing` is kept for no `if_id` at all. +- **KP4-T05's tables match the built rows.** The plan's `vti/add_default` row said local `any`; that row names a local, so it expects 10.0.0.2 and `add_ikey_okey` carries the `any`. The parse table is two tables, one per decoder; each encode table gains `no settings`, vti's an IPv6 remote, a key past 32 bits and `remote any`, xfrm's a dev with `external`, an if_id of 0 and the link's place. The last-key-wins row is built from its words, so its bytes are named. +- **KP5 is four tasks:** hsr; netkit; vrf, vrf_slave and vxcan; batadv with the five kinds `ip` has no parser for. No KP5 parser prefetches, so the encoders take settings alone; netkit's and vxcan's peer halves share `EncodeVethPeer`'s nest. vxcan and batadv have no `fill_info` and the parser-less kinds no `INFO_DATA`, so their tasks test that nothing is there rather than decode it. +- **KP5-T01's word-order row is built out of id order.** `add_abbrev`'s words (`sl d0 slave2 d1 s 7 v 1 p 0`) already come in attribute-id order, so a mutant sorting the attributes by id passed it; the row is built from `s 7 sl d0 p 0 v 1 slave2 d1` instead. The `LinkInfo` negative is an ifb, since no corpus row dumps a dummy as its own device. +- **netkit's peer nest is `putPeerNest`.** `EncodeVethPeer`'s nest body moved into `putPeerNest(ab, atype, peer)`, which netkit calls with `IFLA_NETKIT_PEER_INFO` and KP5-T03's vxcan will with `VXCAN_INFO_PEER`; veth's bytes are unchanged. +- **netkit's empty `INFO_DATA` is not none.** `peer` alone makes `ip` open and close `IFLA_INFO_DATA` with nothing in it, so `EncodeNetkitInfoData` returns an empty, non-nil slice for it and `nil` only for no settings; the test tells the two apart. +- **KP5-T02's tables match the built rows.** The encode table gains a policy with no name (`ErrKindSettingRange`), a peer half with no `peer` word (`ErrKindSettingMissing`), and a built `PEER_INFO`-order row, since the PEER_INFO-first mutant survived every corpus row; the peer-flags, `peer` alone and peer-mode rows are built rather than read from the corpus. The `LinkInfo` negative is a vxcan, since a veth carries no `INFO_DATA` to misdecode. +- **vrf keeps its decoder where it is.** `vrfTableAttr` and the `LinkInfo.VrfTable`/`VrfPortTable` fields stay beside `setLinkInfoNest`; `xtcpnl_vrf.go` holds only the encoder, and the benchmark times `vrfTableAttr`. The `LinkInfo` and `setLinkInfoNest` comments that said only vrf and bond are decoded now point at the per-kind fields. +- **vxcan's encoder takes the peer alone.** `vxcan_parse_opt` refuses any first word but `peer` and hands the rest to `iplink_parse`, so `EncodeVxcanInfoData(peer *VethPeer)` has no settings; a nil peer is no option words, which `ip` sends with no `INFO_DATA` (`iplink.c` calls `parse_opt` only when words remain) (`add_no_option`, `add_up`). +- **KP5-T03's tables match the built rows.** The vrf two-tables row is built out of order (`table 20 table 10`) so an encoder that sorted or kept only the last would fail; `add_table_twice` stays a corpus row. vxcan gains `no option words`, and its bare-word and flags rows are built. `TestKindSlaveDataAbsent` checks `set_type`'s readback beside `set_master`'s. +- **batadv's encoder takes names, not a table.** `batadv_parse_opt` sends each `ra` word unchecked, so `BatadvSetting` holds the name; a NUL inside one is `ErrKindSettingRange`, since no argv word can carry it. There's no decoder: batadv has no `fill_info`, so `LinkInfo` gains nothing. +- **KP5-T04's tables match the built rows.** The two-names row is built out of order (`ra BATMAN_V ra BATMAN_IV`), and the case row is built; `add_ra_twice` and `add_ra_lowercase` stay corpus rows. `TestKindsWithoutData` gains a vcan dump, and its negative row is one subtest per request (46). +- **KG0 is planned now, and each later KG phase when the one before it closes.** The KG phases run in group order, KG0 to KG5. KG0 is two tasks, since its matrix rows need two consecutive clean VM runs before they're gated and the parser doesn't. +- **KG0's exit statuses follow `ip` through a link-only sentinel.** The earlier decision says KG0 matches the status as well as the text. goip exits 1 on every `ErrInvalidArgument`, and `invargErr`, `dupargErr` and `incompleteErr` are shared by most of goip's other objects, so KG0-T01 adds `ErrLinkUsage` (exit 255) and wraps only `parseLinkArgs`' `exit(-1)` refusals. The existing link refusals move from 1 to 255 with it. The bond parser keeps 1 until KG5 reaches the kind parsers' statuses. +- **The `ip`-refused `inet` rows aren't matrix rows.** A write row needs goip to exit 0, so the 7 refusals are held offline by `TestLinkSetInetRefusals`, as `nodev` is by `TestRouteWriteNodevCorpus`. The two the kernel refuses are matrix rows, compared on the request. +- **The `inet` replay uses 7.2.0's lookup mask.** The 7.3.0 corpus was captured with `ip` 7.2.0, whose `ll_link_get` sends `IFLA_EXT_MASK` 0x109 (`RTEXT_FILTER_NAME_ONLY`, `de91e928`). `main_test.go` sets `req.LLMapNameOnly` false for the 7.1.4 fixtures, so the KG0-T01 tests set it true for their run, as `corpus_7_3_0_test.go` does. The goip binary already defaults to 7.2.0's mask. +- **KG0-T01's tables match the built rows.** The replay table names each of the 11 rows, `inet_medium_id_neg2` beside `inet_rp_filter_over` as a kernel refusal built anyway. `TestErrLinkUsageExitCode` is added to pin `exitCodeOf`'s arm order, since `ErrLinkUsage`'s refusals also wrap `ErrInvalidArgument`. + +**2026-10-25:** +- **A section run gates a row; a full run comes before a push.** `goip-parity --section link,addr` (`commands` and `compare` both take `-section`, the runner `--section`) captures and compares only those ip objects' rows, a row's section being the first word of its argv that isn't an option. Two consecutive clean runs of a row's own section gate it. One clean full run comes before any push or PR, since goip's code is shared across sections. A filtered report prints `GOIP_PARITY_SECTION ( of rows)` and the runner's verdict says it isn't the full gate, so the two can't be mistaken for each other. Capture is about 5 s a row, so `link`'s 48 rows take minutes where the table's 518 take about 45. + +- **The inet rows keep their own slug prefix.** They're `link_inet_*`, not in the `link_write_*` cohort, so `TestInetMatrixRows` can find them by prefix and `--section link` still holds them, the section being the argv's object and not the slug. +- **`compare_write_test.go` maps the inet rows to the 7.3.0 rtnl corpus.** Their fixtures are KC1's `7_3_0/link-write/rtnl/inet_*`, so `corpusWriteRow` reads a `link_inet_` row from `inet_` there (`tdRtnl730`), not from the 7.1.4 `rtnl` directory the other rtnl write rows use. +- **KG0-T02's rows are the first gated by a section run.** The full table ran first (V135) and `--section link` second (V136), so they meet the earlier rule (two consecutive clean runs) and the new one (a section run, then a full run before a push) both. `TestInetMatrixRows` gains `positive: every row is gated`, since nothing in the plan's table failed if the allowlist lost a row. +- **KG1 is five tasks.** The words and the renders split by kind, since bridge_slave adds a resolver to each (`backup_port`'s get when parsing, its name when rendering), and the matrix rows are their own task for the two VM runs. +- **The bridge parsers match `ip`'s statuses now.** `invarg` and `incomplete_command` exit 255 through `ErrLinkUsage`, returned refusals 1, as KG0's link refusals do; the bond parsers wait for KG5. +- **goip prints a refusal's first line, not `ip`'s usage.** `unknown command` is followed by `explain()`'s usage in `ip`; goip prints the first line only, as for bond, and the refusal tables compare that line. +- **The bridge `-d` text is held offline.** The matrix compares stdout through its facets, not the kind text, and the timers move between any two runs, so the sidecar tests, each against its own pcap, are the text and JSON oracle; the read rows check the device's other lines and that `-d` no longer refuses. +- **`kindData` and `buildLinkWrite` take `errOut`.** `parse_one_of`'s prefix warning goes to stderr and the parse goes on, so the kind parsers need the command's stderr; `linkWrite` passes `c.errOut`, the tests `io.Discard` or a buffer. +- **goip has `ll_proto_a2n`.** `internal/goip/ll_proto.go` is `lib/ll_proto.c`'s table in its order, for `vlan_protocol` now and the vlan kind's `protocol` in KG2; names compare without case, as `proto_a2n`'s `strcasecmp` does. +- **The bridge argv table is checked against the corpus.** The KC2 rows record no argv, so `bridgeCorpusArgv` writes each row's out from `kind-write-corpus.exp`, and `TestBridgeCorpusArgv` fails if a corpus row has no argv or an argv no row. +- **`nobackup_port` is tried after the bridge_slave chain.** It has no `BridgeSlaveOptions` row, so `bridgeChain` can't find it; tried after the chain it picks the same words as in its place, since no keyword before it matches a prefix of it longer than `n`, and `n` is `neigh_suppress`'s. `TestBridgeSlaveParseChain`'s `no` row holds that. +- **The bridge_slave refusals send p0's get.** `iplink_parse` resolves `dev p0` before `bridge_slave_parse_opt` runs, so each refusal row expects its capture's lookups, p0's and, for `backup_port nosuch`, nosuch's, and no write. +- **The slave kinds have their own `-d` gate.** `IFLA_INFO_SLAVE_KIND` names the master's kind, so a bridge port's is `bridge`, but its print_opt is `bridge_slave_print_opt`; `renderableSlaveInfoData` kept ports refused until KG1-T04 while `renderableInfoData` admitted the bridge. +- **The `ll_proto` table lives in `pkg/xtcpnl`.** The renderer needs `ll_proto_n2a` and can't import goip, so `LLProtoNames` and `LLProtoN2A` move to `xtcpnl_ll_proto.go`; goip's `llProtoA2N` reads the same table. +- **The `show dev` replays pair each get with its reply.** `ip link show dev br0` sends `ll_name_to_index`'s get on a socket of its own and then `iplink_get`'s, both by name, and a dump replay matches both replies. KG1-T03 held the replay to the main socket with `GOIP_REPLAY_PORTID`; KG1-T04 replaced that with `runKindShow`, whose `linkReplay` answers each get with the reply that followed it in the capture and records the gets, so a test can hold them to the capture's requests. +- **The 7.1 mesh sidecar lacks `stp_mode`.** goip's `-d -4 addr show` of the mesh namespace differs from `mesh/ip_addr_v4_n` in `gc_timer`, which moved, and `stp_mode`, which 7.2.0 prints and the 7.1 `ip` that wrote the sidecar doesn't. goip follows the pin; the row checks the status and the sidecar's tokens. +- **`mcast_fast_leave` is a text-only call.** bridge_slave_print_opt prints `IFLA_BRPORT_FAST_LEAVE` twice, the second time with `print_string(PRINT_FP, …)`; `KindData.addText` records a call with no JSON key, which `MarshalJSON` skips, rather than a special case in the renderer. +- **A bridge port with no slave data is `"info_slave_data":{}`.** print_linktype opens the object whenever the slave kind has a print_opt, before print_opt's `if (!tb) return;`, so goip's empty `KindData` marshals as `{}`, as the veth/bridge row of `TestLinkDetailJSONKeys` now expects. +- **The mesh rows render now.** With both bridge print_opts in, `-d link show` and `-d addr show` of the mesh topology exit 0; the link row checks the status and the sidecar's tokens, not the whole text, since gc_timer, stp_mode and nlmon0's promiscuity differ from the 7.1 sidecar. +- **Two bridge rows are respelled for the allowlist.** A command's `Name` is its allowlist key and its argv, so two corpus rows whose argv another row already has, `set_stp_mode_busy` and `set_not_enslaved`, are `link set dev br0 type bridge stp_mode user` and `link set p0 type bridge_slave cost 10`. `dev` is `iplink_parse`'s optional word, so each sends the same request as its corpus row, which `TestCompareWriteRow` holds. +- **The bridge rows read the 7.3.0 kind corpus.** `corpusWriteRow` maps a `link_bridge_` or `link_bridge_slave_` row to `7_3_0/link-write/kinds//` (`tdKinds730`), trying the slave prefix first, since a bridge_slave slug has both. +- **KG1 closes on section runs alone.** Its 96 rows were gated on two `--section link` runs; the full run the rule asks for comes before the next push, not before the gate. +- **KG2 starts with the `link` keyword.** Every KC3 row is stacked on d0 with `link d0`, which goip refused as unimplemented, so KG2-T01 adds it with the vlan words. The vlan corpus is its oracle, and hand-built dummy rows cover what no capture reaches. +- **KG2 is five tasks, one per parser.** macvtap and ipvtap share macvlan's and ipvlan's parsers and print_opts, so they're never tasks of their own. The three renders are small enough to be one task, and the matrix rows are their own task for the two VM runs. +- **macvlan's bad op prints its usage line.** goip prints a refusal's first line and no usage, but `macaddr bogus`'s only text is the usage, so goip prints that line, with the kind's name in it. +- **`ll_addr_a2n`'s warnings are output.** `macaddr set` reads addresses until one fails, and the failing word's warning reaches stderr even when the command succeeds. goip's `llAddrA2N` reports only success, so KG2-T02 adds a variant that names the part `ll_addr_a2n` printed, or none when it failed silently on a length. +- **The plan's vlan flag-order mutant moved.** "The flags in argument order" is `EncodeVlanInfoData`'s, which KP2 held; `parseVlanArgs` only reads values, so KG2-T01's mutant reads `on` as off instead. KG2-T02's "the mode in argument order" is the same, so its mutant tries `nodst` before `nopromisc`. +- **A `macaddr set` re-reads its last word cut.** `ll_addr_a2n` writes a NUL over the first `:` in place, so the word a set stops on goes back to the chain cut there, as `parse_mapping`'s does for vlan. goip copies the cut (`llAddrCut`) rather than refusing the word, so `mode:x` after a set is a `mode`, and seven parts leave `02`, an unknown option. +- **A silent `ll_addr_a2n` refusal gets a goip text.** `ip` exits 1 with nothing on stderr for an add or del address of the wrong length; goip prints `"" is not a 6-byte lladdr`, since its refusals are never empty, and the refusal row holds it apart from `ip`'s `invalid lladdr`. +- **A short protocol or flags attribute prints past.** `vlan_print_opt` reads `IFLA_VLAN_PROTOCOL` and `IFLA_VLAN_FLAGS` without a length check; KP2's decoder drops either when it is short, so goip prints `802.1q` or no flags where `ip` would read past the attribute. The kernel never sends a short one. +- **The kind rows share one slug table.** `kindRowKind` maps a `link__` slug to `7_3_0/link-write/kinds//` for bridge, bridge_slave and the five VLAN family kinds, trying bridge_slave before bridge; `bridgeRowKind` is that table cut to KG1's two kinds, so `TestBridgeMatrixRows` is unchanged. +- **`add_on_busy_lower` is respelled.** It shares `add_default`'s argv, so its row is `link add link d0 dev i0 type ipvlan`; `dev` is `iplink_parse`'s optional word, and the row sends the same request, which `TestCompareWriteRow` holds. +- **KG2 closes on section runs alone,** as KG1 did. The full run comes before the next push. +- **KG3 is five tasks.** vxlan and geneve are large enough to be a task each; bareudp, amt and gtp are small enough to share one. The five renders are one task, and the matrix rows are their own task for the two VM runs. +- **goip makes `ip`'s checks itself.** The KP3 encoders refuse with three errors where `ip` has a text per case, so each parser runs `ip`'s checks in `ip`'s order and returns `ip`'s text. The encoders' refusals stay as a backstop that no goip argv reaches. +- **`ip -4` and `-6`'s default vxlan group is implemented.** With no address given, `ip` sends `IFLA_VXLAN_GROUP` 0.0.0.0 or `GROUP6` `::` for a preferred family. KP3 left it out of the encoder, so KG3-T01 adds `VxlanOptionDefaultGroup`, which the encoder places, rather than refusing `-4`. No capture has it; hand-built rows hold it. +- **A kind's `dev` exits 255.** vxlan's and amt's `dev` is `exit(nodev)`, 255, where `iplink_parse`'s `link` returns `nodev`, 1, so KG3-T01 adds `nodevExitErr` beside `nodevErr`. +- **vxlan's dstport warning goes to stderr.** An add with neither `dstport` nor `gpe` prints four lines and still succeeds. goip prints them and exits 0; the matrix doesn't compare stderr, and the replay row holds the text. +- **amt's bad mode prints its usage line,** as macvlan's bad op does: the usage is its only text. +- **`-d -d` is not `-d` for vxlan.** `vxlan_print_opt` shows its bool options at their defaults when `show_details > 1`, the one such site at `v7.2.0`, so `options.go`'s note that no site tests the count was wrong once vxlan renders. KG3-T04 carries the count to the renderer, and a `-d -d link show dev vx0` read row gates it. +- **amt's mode past relay prints `unknown`.** `ip` indexes past its two-name table. `amt_validate` refuses such a mode, so no device has one, and goip prints gtp's word for a role past its table. +- **Two geneve rows are respelled.** `add_duplicate` and `add_after_external` share an argv, as do `set_local_same` and `set_local_new`, so `add_after_external` and `set_local_new` take `iplink_parse`'s optional `dev`, as KG1's and KG2's respellings did. +- **vxlan's multicast test reads the bytes.** `set_address_type` tests `IN_MULTICAST` and `IN6_IS_ADDR_MULTICAST` on the address as given, so a v4-mapped `::ffff:239.1.1.1` is a unicast remote. netip's `IsMulticast` unmaps it, so the encoder's group and group-without-dev checks now use `vxlanMulticast`. +- **An empty vxlan address is refused.** `any` or `all` after an address of the other family has a family and no bytes, and `ip` sends the attribute empty. A `VxlanSetting` can't hold that, so goip refuses it as not implemented; no capture has it. +- **An `if` dev past INT_MAX is nodev.** `ll_name_to_index` returns it negative and `ip` sends it; goip refuses it with `exit(nodev)`'s text. +- **The not-implemented example is ipoib.** `TestBuildLinkWrite`'s row for a kind goip doesn't build was vxlan; ipoib is out of scope, so it holds the row from now on. +- **`set_op` is an RTM_NEWLINK without NLM_F_CREATE,** as `ip` computes it, so a kind's checks see a `link delete … type vxlan` as an add. +- **The UDP overlays share their words.** `link_kind_args.go` holds what vxlan and geneve spell alike: the keywords, `check_duparg`'s bitmask, `set_address_type`'s address types, and the id, ttl, tos, df and flowlabel readers, whose invarg texts are the same in both files. Each kind keeps its own chain and checks. `overlayData` keeps `kindData` under funlen. +- **geneve's encoder reads multicast from the bytes too.** It refused a v4-mapped multicast remote that `ip` sends as `REMOTE6`, as vxlan's did, so both now use `kindMulticast`. +- **amt's `any` after a discovery is not implemented.** With a discovery's family set, `get_addr` gives `any` or `all` an empty address, which `ip` sends as an empty attribute; an `xtcpnl.AmtSetting` can't hold one, so goip refuses it as not implemented. +- **amt's `dev` is vxlan's.** A name that doesn't resolve, or an `if` past INT_MAX, is `exit(nodev)`, 255, through `nodevExitErr`. +- **The small overlays' refusal rows are named by kind.** KC4's corpus reuses row names across bareudp, amt and gtp, so the test names each `/`. +- **missarg has one helper.** bareudp's missing dstport and ethertype go through `missargErr`, `ip`'s `Error: argument "" is required` at 255. +- **goip's typed show path refuses `-d -d`.** Run carries the `-d` count to the link render, but a `ShowRequest` holds `-d` as a bool, so `ResolveShow` refuses a count above one, as it refuses `-s -s`, rather than answer `-d -d` with `-d`'s render. +- **vxlan's and amt's `dev` is a get.** `ll_index_to_name` on the lower device sends `RTM_GETLINK` for an index not cached, so `resolveLinkRefs` resolves it after bond's indexes and before bridge_slave's `backup_port`, in `print_linktype`'s order. +- **amt's mode past relay prints `unknown`.** `ip` indexes past `modename`; goip prints gtp's word, and `amt_validate` refuses such a mode, so no device carries one. +- **The VLAN family test names its kinds.** With the UDP overlays in `kindRowKinds`, `TestVlanFamilyMatrixRows` keeps the rows whose kind is one of its five, where it had dropped bridge's two. +- **Each kernel-refused row says why.** A rc 2 row's comment is the last line of its `ip.err`, the kernel's extack or `RTNETLINK answers` text. +- **KG3 closes on section runs alone,** as KG1 and KG2 did. The full run comes before the next push. +- **vxlan's `ageing` rows split the keyword.** misspell holds string literals to US spelling, so the three rows that carry it build their names and slugs from `vxlanAgeingCst`, and the earned list splits it, as KG1's bridge rows do for `ageing_time`. +- **KG4 is nine tasks.** The fallback-device fix comes first, since every tunnel row needs it. The words split by `ip`'s parser files: gre, gre6, iptnl, ip6tnl, and vti with xfrm. The renders split into the gre kinds and the rest, and the matrix rows are their own task for the two VM runs. +- **goip-parity sets the fallback sysctl before its first row.** `late_setup` set it only at the first row that isn't `route`, which a `--section link` run never reaches and a full run reaches after every link row. So the first row of each tunnel kind would load its module, and give every later namespace an any/any fallback device. The sysctl step moves into `nltopo::no_fallback_tunnels`, which the driver calls before the loop and `late_setup` still calls first. +- **The prefetch is built once.** Every tunnel parser but xfrm's reads the device back on a set. KG4-T02 adds the 32-byte get with no `IFLA_EXT_MASK` and the `get_failed` checks, and the later tasks reuse them. +- **The prefetch runs without `NLM_F_CREATE`.** That is `ip`'s test, so `ip link delete … type gre ` prefetches too, and goip keys it on `!verb.create()`, not `setOp()`. +- **The prefetch reads raw `IFLA_INFO_DATA`.** `ip` reads the reply by its own parser's ids whatever the reply's kind, and fails on an endpoint of the wrong length or family. goip checks the raw endpoint lengths as `get_addr_rta` does. A reply outside the parser's kinds that passes them is not implemented, since `ip` would seed from another kind's attributes. +- **The prefetch's two `ip` bugs are copied.** The byte-swapped encap port and vti's low-byte `LINK` go back as `ip` sends them, as the KP4 encoders already do. +- **A tunnel's `help` is its usage line.** No tunnel parser has a `help` word, so `ip` prints the usage for it as for any unknown word, exit 1. goip prints the usage's first line, where KG3's kinds, which do have a `help` word, refuse it as not implemented. +- **Five tunnel cases are not implemented:** sit's `6rd-reset`, which segfaults `ip` 7.2.0; ip6tnl's `encaplimit` with a value that isn't a u8, which sends an uninitialized byte; sit's `6rd-prefix any` and `6rd-relay_prefix any`, which send an empty address no `IptunSetting` holds; and a prefetch reply of a kind outside the parser's. +- **gre's and iptnl's parsers take `cur`.** gre's broadcast check and iptnl's ttl check read the seeded endpoints and values, before `external`, so the parser makes them with `ip`'s text and the encoders' refusals stay a backstop. +- **Three tunnel rows are respelled.** `ip6gretap/add_beside_ip6gre`, `ip6tnl/set_collide` and `xfrm/set_drop_dev` share an argv with another row, so each takes `iplink_parse`'s optional `dev`. + +**2026-11-01:** +- **gre's chain spells three words as constants.** goconst counts a literal across the package, so `seq`, `nopmtudisc` and `pmtudisc`, which l2tp's and xfrm state's words also use, are `greSeq`, `greNopmtudisc` and `grePmtudisc` in `link_gre_args.go`. +- **The prefetch's tests add two rows.** `TestTunnelPrefetch` holds a reply with `IFLA_LINKINFO` but no `IFLA_INFO_DATA` beside the plan's no-`IFLA_LINKINFO` row, since `ip` fails on each separately, and the plan's seeded-multicast refusal moved there from `TestLinkGreRefusals`, as it needs a built reply. `TestParseGreArgs` adds `d d0`, the `dev` abbreviation resolving. +- **KG4-T03: gre and gre6 share their chain's head.** `greCommonChain` holds the key, seq and csum words, the endpoints, dev and ttl's and tos's aliases. gre's `[no]pmtudisc` moves from before `remote` to after `dsfield`. That is the same chain, since none of the words between starts with `n` or `p`. The tos aliases are gre6's tclass, read in hex; `fl` is strcmp, so `f` is a usage error (the row V199's `fl` mutant needed). +- **KG4-T04: a 6rd prefix's length is bound-checked, not suppressed.** `get_prefix`'s bitlen is an `int`; goip refuses one below 0 or past the address's width before the `uint64` conversion, so gosec needs no exclusion. A 6rd prefix of `any`, `all` or `default` (bitlen -2) is `ErrNotImplemented`: `ip` parses it and sends nothing, a case with no capture. +- **KG4-T07: the decoder keeps each endpoint's length.** `tnl_print_endpoint` reads the attribute's length as the family (4, 16, else `unknown`), and a `netip.Addr` can't say what arrived, so `GreInfo` gains `LocalLen` and `RemoteLen` (the last attribute's) and `HasLocal` and `HasRemote`. KG4-T08 adds the same to `IptunInfo` and `VtiInfo`. +- **KG4-T08: gre6 and ip6tnl share their flow words.** `tnl6Flow` (encaplimit, tclass, flowlabel and dscp) and `tnlFwmark` move to `link_tunnel.go`, so the two print_opts' identical blocks are one. xfrm's link is decoded but never printed, as `xfrm_print_opt` has it. +- **KG4-T08: the `-d` refusal is tested on hsr and netkit.** Its old corpus, the 7_1_4 tunnel topology, renders in full now. hsr's and netkit's readbacks are single gets, which `GOIP_REPLAY` can't replay, so the new test drives `runLink` through `linkReplay` as the kind tests do. +- **KG4-T09: the prefetch's volatile fields are control noise, not failures.** The 32-byte prefetch's reply is a whole `RTM_NEWLINK`, so its IPv6 cacheinfo timestamps and gretap's random MAC differ between `ip`'s two control legs. The comparator already suppresses what the control legs disagree on, so `CONTROL_NOISY` rises from 4 to 84 and every row still passes; no per-kind suppression is added. +- **KG5 is seven tasks.** The kinds with no parser need only dummy's else arm and two link keywords, so they come first. The words split by `ip`'s parser files: hsr, netkit, then vrf, vxcan and batadv. The bond statuses KG0 left are a task of their own, the two renders another, and the matrix rows are their own task for the two VM runs. +- **54 of KC6's 122 requests already match goip.** A throwaway replay found them: every kind with no words, a set of only `type K`, and the `master`, `nomaster` and `mtu` rows. Each task's table still lists every row of its kinds, so a regression in a matching row is caught. +- **hsr's `version` and `proto` are copied as written.** goip sends any u8, as `ip` 7.2.0 does, and `version 2` is the kernel's refusal (`hsr/add_version_2`, rc 2). KP5's encoder already takes any u8. +- **sit's `6rd-reset` stays `ErrNotImplemented`.** KG4-T04 refuses it; `ip` 7.2.0 crashes on it, so there is no behavior to match. +- **Four help rows stay `ErrNotImplemented`.** `ifb/add_help_abbrev`, `team/add_help`, `netkit/add_peer_help` and `vxcan/add_peer_help` print `ip link`'s whole usage and exit 255, which goip refuses for dummy and `ip link help` too. +- **vxcan's usage goes to stderr in goip.** `ip` prints it to stdout; goip prints every refusal as `goip: ` on stderr. The status, 1, matches, and the three rows' tables check the text against `ip.out`. +- **Six KG5 rows are respelled.** `batadv/add_duplicate`, `vrf/add_shared_table`, `team/set_enslave_again`, `vrf_slave/set_nomaster`, `team_slave/set_unenslaved` and `vcan/set_mtu_up` share an argv with another row, so each takes `iplink_parse`'s optional `dev`. +- **KG5-T01 adds two tables beside the plan's.** The help rows and the queue counts' accepted values are `buildLinkWrite` calls rather than replays (`TestLinkNoParserHelp`, `TestLinkQueueCounts`), since no capture holds them; the no-parser kinds are a case list in `kindData`'s switch, not a named set. Its built refusal `numtxqueues type ifb` records that `ip` reads `type` as the count. +- **KG5-T03's mutants are what goip's structure allows.** goip parses the peer into its own `linkParsed`, so "the device's flags not saved" and "the peer's flags left on the device" from the plan become the device's flags copied into the peer and the peer's flags dropped; both are killed. +- **KG5-T04 differs from its plan in three places.** `rtnlNameA2N` over `routeTableNames` was already a helper (`route_write.go`), so nothing was factored out of `routeParseState.table`. vrf's and batadv's parsers share `link_vrf_args.go` rather than a `link_batadv_args.go`. And veth's peer `type`, which goip refused at 1, is `duparg`'s 255 in `ip`; vxcan shares the code, so both now exit 255, with a row for each. +- **KG5-T05 sends a negative ifindex rather than refusing it.** The plan's corner row had a resolver's -1 refused. `ip` tests `primary`'s, `active_slave`'s, `master`'s, `vrf`'s and hsr's ifindex only for 0 and copies the int into the u32, and `ll_idx_a2n`'s `if%u` can return one past `INT32_MAX`, so goip sends the bits (`TestLinkIfindexBits`); KG5-T01's `vrf` and KG5-T02's hsr words, which refused one, now do the same. Six `//nolint:gosec` went, not two: the master arm, `intAttr`, the bond resolver, `ll_idx_a2n`'s two and the bond s32. +- **KG5-T06 needed the slaves' gets.** `hsr_print_opt`'s `ll_index_to_name` sends a get for each slave and the interlink, so `resolveLinkRefs` sends them, in print order, unless a short attribute makes the print_opt return first. The decoder already kept `Short`, so it gained no raw lengths. The two netkit and hsr files are one, `render/link_hsr.go`. +- **KG5-T07 differs from its plan in three places.** team_slave's read rows are `-d link show d0` and `-d -j link show d0`, without `dev`, since vrf_slave's read rows already spell `dev d0`. The plan's corner row checked one floor that counts a lookup, `vrf_slave/set_vrf`'s 8; the row covers the other two, `hsr/add_interlink` (8: the slaves' and the interlink's gets) and `netkit/set_peer_name` (6). And no module went into `nltopo::late_modules`: each kind's first row, which loads its module, passed on all three legs in both runs (V241, V242). diff --git a/docs/netlink/goip-link-kinds.md b/docs/netlink/goip-link-kinds.md new file mode 100644 index 000000000..6a8b2ac4b --- /dev/null +++ b/docs/netlink/goip-link-kinds.md @@ -0,0 +1,4354 @@ +# goip ↔ `ip` parity: link kinds and iproute2 7.2.0's `link set inet` + +## Status + +**Last updated:** 2026-11-02 + +**Summary:** +- [goip-parity-gaps.md](goip-parity-gaps.md) is finished. C0 to G7 are on `main` (#280, `31bff973`), and the parity VM passes 507 of 507 rows against `ip` 7.2.0. +- That plan's inventory counted objects and verbs, not link kinds: + - iproute2 7.2.0 has 39 `ip/iplink_*.c` and `ip/link_*.c` files. + - goip covered a handful of the kinds they define when this plan was written; it now covers every in-scope kind in [the inventory](#the-kind-inventory) (see [Where goip stands](#where-goip-stands)). + - Every other kind is refused rather than wrongly answered. +- This document plans the rest: + - every link kind a VM can create with only a module; + - the one new write the 7.2.0 pin brings, `ip link set dev X inet …`. +- The tiers are the same as before, in the same order: captures (KC), then parsers with table tests and benchmarks (KP), then goip and the matrix (KG). +- **Done (2026-11-02):** KC0 to KG5 are closed, and goip-parity's table is 1246 rows. [Where goip stands](#where-goip-stands) lists what each kind landed with. + +**Progress** is tracked in [goip-link-kinds-status.md](goip-link-kinds-status.md): the next action, the phase states, the task checklist, the verification ledger and the decision log. This document changes only when a task refines its own plan, or when a phase close updates the [inventory](#the-kind-inventory). + +## Contents + +- [Reference sources](#reference-sources) +- [Where goip stands](#where-goip-stands) +- [The kind inventory](#the-kind-inventory) +- [What 7.2.0 adds](#what-720-adds) +- [Out of scope](#out-of-scope) +- [The per-kind pipeline](#the-per-kind-pipeline) +- [Gates every task inherits](#gates-every-task-inherits) +- [Phases and tasks](#phases-and-tasks) +- [See also](#see-also) + +## Reference sources + +These are the trees [goip-parity-gaps.md](goip-parity-gaps.md#reference-sources) uses, and they are read-only in the same way. Two things differ: +- **The `ip` pin is 7.2.0** (`nix/upstream-pins.json`). Read it with `git -C ~/Downloads/iproute2 show v7.2.0:ip/`. Line numbers here are from `v7.2.0`. The checkout is a few commits past the tag (`v7.2.0-34`), so the tag, not the working tree, is the source of truth. +- **Kernel line numbers** are from the local 7.3.0-rc6 tree, as before. The guest kernel is `linuxPackages_latest` (`nix/microvms/mkVm.nix`), 7.3.0 at KC0. + +## Where goip stands + +Since KG5 closed, goip parses the words of every kind in the inventory and renders the `-d` output of every kind KC captured, matched against `ip` 7.2.0 on KC's corpus and in goip-parity's VM rows. + +| kind | decode (`pkg/xtcpnl`) | encode | goip args | `-d` render | +|---|---|---|---|---| +| bond, bond_slave | `xtcpnl_bond.go` | `encodeBondSettings` (`xtcpnl_link_write.go`) | `link_bond_args.go`; `ip`'s exit statuses since KG5-T05 | `render/link_bond.go` | +| vrf, vrf_slave | the table id | `EncodeVrfInfoData` (KP5) | `link_vrf_args.go` (KG5-T04); `iplink_parse`'s `vrf NAME` (KG5-T01) | `render/vrf.go` | +| dummy, veth | no `INFO_DATA` | veth's peer | `link add` with no options; veth's peer through `vethData` | no `INFO_DATA` to render | +| bridge, bridge_slave (KP1) | `xtcpnl_bridge.go`, `xtcpnl_bridge_slave.go` | `EncodeBridgeInfoData`, `EncodeBridgeSlaveData` | `link_bridge_args.go`, `link_bridge_slave_args.go` (KG1) | `render/link_bridge.go`, `render/link_bridge_slave.go` (KG1) | +| vlan, macvlan, macvtap, ipvlan, ipvtap (KP2) | `xtcpnl_vlan.go`, `xtcpnl_macvlan.go`, `xtcpnl_ipvlan.go` | `EncodeVlanInfoData`, `EncodeMacvlanInfoData`, `EncodeIpvlanInfoData` | `link_vlan_args.go`, `link_macvlan_args.go`, `link_ipvlan_args.go` (KG2) | `render/link_vlan.go`, `render/link_macvlan.go`, `render/link_ipvlan.go` (KG2) | +| vxlan, geneve, bareudp, amt, gtp (KP3) | `xtcpnl_vxlan.go`, `xtcpnl_geneve.go`, `xtcpnl_bareudp.go`, `xtcpnl_amt.go`, `xtcpnl_gtp.go` | `EncodeInfoData` for each | `link__args.go` for each (KG3) | `render/link_.go` for each (KG3) | +| gre, gretap, erspan, ip6gre, ip6gretap, ip6erspan, ipip, sit, ip6tnl, vti, vti6, xfrm (KP4) | `xtcpnl_gre.go`, `xtcpnl_iptun.go`, `xtcpnl_vti.go`, `xtcpnl_xfrm_link.go` | `EncodeGreInfoData`, `EncodeGre6InfoData`, `EncodeIptunInfoData`, `EncodeIp6tnlInfoData`, `EncodeVtiInfoData`, `EncodeXfrmLinkInfoData` | `link_gre_args.go`, `link_gre6_args.go`, `link_iptnl_args.go`, `link_ip6tnl_args.go`, `link_vti_args.go`, `link_xfrm_args.go`, with the set prefetch in `link_tunnel_args.go` (KG4); sit's `6rd-reset` refused | `render/link_gre.go`, `render/link_iptun.go`, `render/link_vti.go`, `render/link_tunnel.go` (KG4) | +| hsr, netkit, vxcan, batadv (KP5) | `xtcpnl_hsr.go`, `xtcpnl_netkit.go`; vxcan and batadv send no `INFO_DATA` | `EncodeHsrInfoData`, `EncodeNetkitInfoData`, `EncodeVxcanInfoData`, `EncodeBatadvInfoData` | `link_hsr_args.go`, `link_netkit_args.go`, vxcan through `vethData`, batadv in `link_vrf_args.go` (KG5) | `render/link_hsr.go`, hsr's and netkit's (KG5-T06); vxcan and batadv have no `print_opt` | +| ifb, nlmon, vcan, team, team_slave | no `INFO_DATA` (KP5-T04 tests it) | no options | `link add` with no options; a word after the type is dummy's `Garbage instead of arguments` (KG5-T01) | no `INFO_DATA` to render | +| every other kind | no | no | refused | refused | + +The two refusals left: +- **`link add … type X `:** `kindData` (`internal/goip/link_write.go:694`) returns `ErrNotImplemented` for a kind it has no case for (`:778`), which is now only the [out of scope](#out-of-scope) kinds. Four help rows, which print `ip link`'s whole usage, stay `ErrNotImplemented` too. +- **`-d link show`:** `checkDetailSupported` (`internal/goip/obj_link.go:372`) refuses a device carrying `IFLA_INFO_DATA` that `renderableInfoData` (`:400`) doesn't accept, which is now only a kind outside the inventory. + +`ip link set dev X inet …` is in goip's keyword chain since KG0-T01 (`linkParseKeywords`, `(*linkParseState).inet`), over KP0's codec (`xtcpnl_inet_devconf.go`: `LinkAttrInetDevconf`, `ParseInetDevconfRequest`, `ParseInetDevconfValues` and the `InetDevconfParams` table). Its matrix rows are KG0-T02's. + +## The kind inventory + +One row per iproute2 file, at `v7.2.0`: +- **ids:** the `link_util` ids the file registers. +- **print:** the lines in its `print_opt`. "—" means it has none. +- **attrs:** the members of the kernel's `IFLA__*` enum after `UNSPEC`, counting `PAD`. +- **module:** what `modprobe` loads. +- **hazards:** the `ll_index_to_name` calls (an ifindex printed as a name) and the `format_host` calls (an address printed through the resolver, which reverse-resolves only with `-r`). + +| group | file | ids | print | attrs | module | hazards | +|---|---|---|---|---|---|---| +| K1 | `iplink_bridge.c` | bridge | 332 | 50 (`IFLA_BR_*`) | bridge | 1 `ll_index_to_name` | +| K1 | `iplink_bridge_slave.c` | bridge_slave | 196 | 45 (`IFLA_BRPORT_*`) | bridge | 1 `ll_index_to_name` | +| K2 | `iplink_vlan.c` | vlan | 48 | 6 | 8021q | | +| K2 | `iplink_macvlan.c` | macvlan, macvtap | 95 | 9 | macvlan, macvtap | | +| K2 | `iplink_ipvlan.c` | ipvlan, ipvtap | 32 | 2 | ipvlan, ipvtap | | +| K3 | `iplink_vxlan.c` | vxlan | 179 | 34 | vxlan | 1 `ll_index_to_name`, 6 `format_host` | +| K3 | `iplink_geneve.c` | geneve | 152 | 18 | geneve | 4 `format_host` | +| K3 | `iplink_bareudp.c` | bareudp | 28 | 4 | bareudp | | +| K3 | `iplink_amt.c` | amt | 49 | 9 | amt | 1 `ll_index_to_name`, 3 `format_host` | +| K3 | `iplink_gtp.c` | gtp | 23 | 8 | gtp | | +| K4 | `link_gre.c` | gre, gretap, erspan | 125 | 24 (`IFLA_GRE_*`) | ip_gre | 1 `ll_index_to_name` | +| K4 | `link_gre6.c` | ip6gre, ip6gretap, ip6erspan | 165 | 24 (`IFLA_GRE_*`) | ip6_gre | 1 `ll_index_to_name` | +| K4 | `link_iptnl.c` | ipip, sit | 122 | 20 (`IFLA_IPTUN_*`) | ipip, sit | 1 `ll_index_to_name`, 2 `format_host` | +| K4 | `link_ip6tnl.c` | ip6tnl | 123 | 20 (`IFLA_IPTUN_*`) | ip6_tunnel | 1 `ll_index_to_name` | +| K4 | `link_vti.c` | vti | 44 | 6 | ip_vti | 1 `ll_index_to_name` | +| K4 | `link_vti6.c` | vti6 | 44 | 6 | ip6_vti | 1 `ll_index_to_name` | +| K4 | `link_xfrm.c` | xfrm | 19 | 3 | xfrm_interface | | +| K5 | `iplink_hsr.c` | hsr | 64 | 8 | hsr | 3 `ll_index_to_name` | +| K5 | `iplink_netkit.c` | netkit | 41 | 10 | built in (`CONFIG_NETKIT` is `bool`) | | +| K5 | `iplink_vrf.c` | vrf, vrf_slave | 24 | 2 | vrf | | +| K5 | `iplink_vxcan.c` | vxcan | — | peer only | vxcan | | +| K5 | `iplink_batadv.c` | batadv | — | 1 (`IFLA_BATADV_ALGO_NAME`) | batman_adv | | +| K5 | `iplink_ifb.c`, `iplink_nlmon.c`, `iplink_vcan.c` | ifb, nlmon, vcan | — | none | ifb, nlmon, vcan | | +| K5 | `iplink_team.c` | team, team_slave | — | none | team | | +| done | `iplink_bond.c`, `iplink_bond_slave.c` | bond, bond_slave | 340, 88 | | bonding | 3 `ll_index_to_name` | +| done | `iplink_dummy.c`, `link_veth.c` | dummy, veth | — | none | dummy, veth | | +| out | `iplink_can.c`, `iplink_ipoib.c`, `iplink_dsa.c`, `iplink_rmnet.c`, `iplink_wwan.c`, `iplink_virt_wifi.c`, `iplink_netdevsim.c` | can, ipoib, dsa, rmnet, wwan, virt_wifi, netdevsim | | | | see [Out of scope](#out-of-scope) | +| out | `iplink_xdp.c`, `iplink_xstats.c` | not kinds | | | | see [Out of scope](#out-of-scope) | + +`ll_index_to_name` is the hazard that matters: a link, lower or port index printed as a name. goip's renderer already takes a `NameTab` for this (`render.kindData`), and each such field needs a row whose index names a device in the namespace and a row whose index names none. + +## What 7.2.0 adds + +**`ip link set dev X inet …`** (`ip/iplink.c:572-860`, the keyword at `:946-947`). It is the only new write on the pin. +- **Layout:** `IFLA_AF_SPEC` → `AF_INET` → `IFLA_INET_CONF` → one u32 per `IPV4_DEVCONF_*`, keyed by the devconf id. Only the `IFLA_INET_CONF` nest carries `NLA_F_NESTED`. +- **Keywords** are matched with `strcmp`, not `matches`, so there are no abbreviations. +- **23 on/off params**, read with `parse_on_off`: forwarding, proxy_arp, accept_redirects, secure_redirects, send_redirects, shared_media, accept_source_route, bootp_relay, log_martians, arp_filter (`ARPFILTER`), disable_xfrm (`NOXFRM`), disable_policy (`NOPOLICY`), promote_secondaries, arp_notify, accept_local, src_vmark, proxy_arp_pvlan, route_localnet, bc_forwarding, ignore_routes_with_linkdown, drop_unicast_in_l2_multicast, drop_gratuitous_arp, arp_evict_nocarrier. +- **9 numeric params**, read with `get_integer` in base 0 (so `0x` and leading-`0` octal are accepted): rp_filter, tag, medium_id, force_igmp_version, arp_announce, arp_ignore, arp_accept, igmpv2_unsolicited_report_interval, igmpv3_unsolicited_report_interval. +- **The end of the list:** the first word that is no param is pushed back (`PREV_ARG`), and `iplink_parse` carries on with it. +- **Errors:** a bad numeric value is `Invalid "" value`. A missing value, or `inet` with no known param after it, is `incomplete_command`. + +**Kind options new in 7.2.0:** +- bridge `stp_mode` (`IFLA_BR_STP_MODE`); +- bridge_slave `neigh_forward_grat`; +- geneve `local`, one keyword that sends `IFLA_GENEVE_LOCAL` or `IFLA_GENEVE_LOCAL6` by the address's family. + +They are rows in K1 and K3 like any other option. + +**maddress is not one of them.** `ip maddress` moved to `RTM_GETMULTICAST` in iproute2 `b8cdda55`, which is after the tag (`v7.2.0-34`). At the 7.2.0 pin, `ip/ipmaddr.c` still reads `/proc/net/dev_mcast`, `igmp` and `igmp6` (`:95`, `:136`, `:179`), so it stays class F until the next pin bump. [goip-parity-gaps.md](goip-parity-gaps.md#f-not-netlink-outside-pcap-parity) is corrected to match. + +## Out of scope + +Each exclusion is recorded in the [decision log](goip-link-kinds-status.md#decision-log). +- **Kinds that need hardware or another creation path:** + - can (vcan takes none of its options, and a real CAN device is hardware); + - ipoib, dsa, rmnet, wwan, virt_wifi, which each need a lower device the VM doesn't have; + - netdevsim, which is created through sysfs, not rtnetlink. +- **XDP:** `iplink_xdp.c`'s `-d` output makes a `bpf()` syscall per program, which nlmon can't see. It is class F. +- **xstats:** it isn't a kind. `link xstats` is already a goip read (G1). +- **maddress:** class F at the pin, as above. +- **Applying goip-built writes:** goip's writes stay dry runs, as in every earlier write design. + +## The per-kind pipeline + +Bond is the template. A kind is done when each step below has landed and its matrix rows are gated: +1. **Captures** (KC): `link add` with the defaults, `link add` with each option `ip` parses, `link set … type X