feat(corpus): field-level learning seam schema + classify migration (ag-2srq1 #field-level-seam)

S3 of epic ag-k7tq9 (corpus private/public separation). Formalize the
promote-time, field-level seam from the cross-family council verdict: the
corpus is lossless and private-by-default; only an abstracted lesson crosses
into the public wiki, gated by two fail-closed frontmatter fields.

- schemas/learning.v1.schema.json: +sensitivity (enum unknown/private/public,
  default unknown) +publishable (bool, default false) — allowlist ceiling
- docs/contracts/corpus-learning-seam.md: field-boundary SOT (lesson crosses;
  evidence/provenance/source_session never do); cites the council verdict
- ao corpus classify: dry-run-by-default annotator; operates on the YAML fence
  textually (never parses the body) so one junk record can't abort; idempotent;
  skips meta docs (CORPUS-POLICY.md, README.md, ...)
- L1 + L2 tests for the annotator, the dir walk, and the command

Applied to the private corpus (boshu2/agentops-corpus): 342 learnings annotated
with safe defaults, 1 meta doc skipped, idempotent. Boundary verified — 0
.agents/learnings changes in the public repo.

Closes-scenario: ag-2srq1#field-level-seam
Bounded-context: BC1-Corpus
Evidence: docs/contracts/corpus-learning-seam.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Boden Fuller
2026-06-15 16:06:52 -04:00
parent 3f4279d26f
commit 5b657ff0d3
15 changed files with 692 additions and 4 deletions
+90
View File
@@ -0,0 +1,90 @@
// practices: [fail-closed-safety, wiki-knowledge-surface]
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/boshu2/agentops/cli/internal/corpus"
)
var (
corpusClassifyApply bool
corpusClassifyJSON bool
)
// corpusClassifyCmd annotates learning records with the two promote-gate
// frontmatter defaults (sensitivity=unknown, publishable=false) — the S3 seam
// migration (epic ag-k7tq9). Dry-run by default; --apply writes.
var corpusClassifyCmd = &cobra.Command{
Use: "classify <dir>",
Short: "Annotate learning frontmatter with promote-gate defaults (sensitivity, publishable)",
Long: `Ensure every learning record under <dir> carries the two promote-gate
frontmatter fields with SAFE defaults:
sensitivity: unknown # un-triaged ceiling; not a capture property
publishable: false # promotion allowlist flag; inclusion is earned
This is the field-level seam migration from the corpus public/private council
verdict (.agents/council/2026-06-15-corpus-private-public-seam-verdict.md): the
corpus is lossless and private-by-default, and only sensitivity==public AND
publishable==true items may later be promoted to the public wiki (allowlist,
fail-closed — default excludes).
It is malformed-tolerant: it operates on the frontmatter fence textually and
never parses the (possibly broken) YAML body, so a single junk record cannot
abort the run. An existing real decision (any sensitivity/publishable value) is
never overwritten. Meta docs (CORPUS-POLICY.md, README.md, …) are skipped.
Dry-run by default — prints what WOULD change. Pass --apply to write.
ao corpus classify .agents/learnings # dry run
ao corpus classify .agents/learnings --apply # write defaults`,
Args: cobra.ExactArgs(1),
RunE: runCorpusClassify,
}
func init() {
corpusCmd.AddCommand(corpusClassifyCmd)
corpusClassifyCmd.Flags().BoolVar(&corpusClassifyApply, "apply", false, "Write the changes (default: dry run, report only)")
corpusClassifyCmd.Flags().BoolVar(&corpusClassifyJSON, "json", false, "Emit the report as JSON")
}
// runCorpusClassify is the RunE entry point for `ao corpus classify`.
func runCorpusClassify(cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true
dir := args[0]
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
return fmt.Errorf("corpus classify: %q is not a directory", dir)
}
res, err := corpus.ClassifyDir(dir, corpusClassifyApply)
if err != nil {
return fmt.Errorf("corpus classify: %w", err)
}
if corpusClassifyJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(res)
}
mode := "dry run — no files written (pass --apply to write)"
if res.Applied {
mode = "applied"
}
fmt.Printf("Corpus classify (%s):\n", mode)
fmt.Printf(" scanned learnings: %d\n", res.Scanned)
fmt.Printf(" skipped meta docs: %d\n", res.Skipped)
fmt.Printf(" needing defaults: %d\n", res.Changed)
if res.Changed > 0 && !res.Applied {
fmt.Println("\nWould annotate:")
for _, f := range res.ChangedFiles {
fmt.Printf(" - %s\n", f)
}
}
return nil
}
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestRunCorpusClassify_DryRunReportsButDoesNotWrite drives the `ao corpus
// classify` command entry point in its default (dry-run) mode against a temp
// corpus and asserts it reports the right counts without touching disk.
func TestRunCorpusClassify_DryRunReportsButDoesNotWrite(t *testing.T) {
dir := t.TempDir()
orig := "---\ndate: 2026-06-14\n---\nbody\n"
p := filepath.Join(dir, "a.md")
if err := os.WriteFile(p, []byte(orig), 0o644); err != nil {
t.Fatal(err)
}
corpusClassifyApply = false
out, err := captureStdout(t, func() error {
return runCorpusClassify(corpusClassifyCmd, []string{dir})
})
if err != nil {
t.Fatalf("runCorpusClassify: %v", err)
}
if !strings.Contains(out, "dry run") {
t.Errorf("expected dry-run banner, got:\n%s", out)
}
if !strings.Contains(out, "needing defaults: 1") {
t.Errorf("expected 1 record needing defaults, got:\n%s", out)
}
got, _ := os.ReadFile(p)
if string(got) != orig {
t.Errorf("dry run modified the file:\n%s", got)
}
}
// TestRunCorpusClassify_ApplyWritesDefaults drives `ao corpus classify --apply`
// and asserts the safe defaults are written to the learning frontmatter.
func TestRunCorpusClassify_ApplyWritesDefaults(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "a.md")
if err := os.WriteFile(p, []byte("---\ndate: 2026-06-14\n---\nbody\n"), 0o644); err != nil {
t.Fatal(err)
}
corpusClassifyApply = true
t.Cleanup(func() { corpusClassifyApply = false })
if err := runCorpusClassify(corpusClassifyCmd, []string{dir}); err != nil {
t.Fatalf("runCorpusClassify --apply: %v", err)
}
got, _ := os.ReadFile(p)
if !strings.Contains(string(got), "sensitivity: unknown") || !strings.Contains(string(got), "publishable: false") {
t.Errorf("apply did not write defaults:\n%s", got)
}
}
// TestRunCorpusClassify_NonDirRejected asserts a non-directory argument is a
// clean error, not a panic.
func TestRunCorpusClassify_NonDirRejected(t *testing.T) {
corpusClassifyApply = false
err := runCorpusClassify(corpusClassifyCmd, []string{filepath.Join(t.TempDir(), "nope")})
if err == nil {
t.Fatal("expected error for a non-directory argument")
}
if !strings.Contains(err.Error(), "not a directory") {
t.Errorf("unexpected error: %v", err)
}
}
+16
View File
@@ -3138,6 +3138,22 @@ ao corpus capture --path <relpath> [--body <text>] [--body-file <file>] [--body-
--root string corpus root (default: .agents/learnings/)
```
#### `ao corpus classify`
Ensure every learning record under <dir> carries the two promote-gate
```
ao corpus classify <dir> [flags]
```
**Flags:**
```
--apply Write the changes (default: dry run, report only)
-h, --help help for classify
--json Emit the report as JSON
```
#### `ao corpus fitness`
Compute the corpus-quality fitness vector for the current .agents/
+138
View File
@@ -0,0 +1,138 @@
// practices: [fail-closed-safety, wiki-knowledge-surface]
package corpus
import "strings"
// The two promote-gate fields S3 (epic ag-k7tq9) adds to every learning's
// frontmatter. They are the CEILING the publish pipeline (S5/S6) reads — NOT a
// capture property. Per the unanimous cross-family council verdict
// (.agents/council/2026-06-15-corpus-private-public-seam-verdict.md): the corpus
// is lossless and private-by-default; sensitivity is decided at promote time.
//
// Allowlist, fail-closed: only sensitivity==SensitivityPublic AND
// publishable==true may cross the seam into docs/wiki. Default excludes.
const (
// SensitivityField is the frontmatter key for the publish-gate ceiling.
SensitivityField = "sensitivity"
// PublishableField is the frontmatter key for the promotion allowlist flag.
PublishableField = "publishable"
// SensitivityDefault is the safe default for an un-triaged learning: nothing
// is publishable until it is affirmatively reviewed and cleared.
SensitivityDefault = "unknown"
// PublishableDefault is the safe default: inclusion is earned, never assumed.
PublishableDefault = "false"
)
// metaFilenames are corpus files that are NOT learning records and must never
// be annotated (policy/readme/index docs that live alongside the learnings).
var metaFilenames = map[string]bool{
"CORPUS-POLICY.md": true,
"README.md": true,
"MEMORY.md": true,
"INDEX.md": true,
}
// IsLearningFile reports whether a corpus file (identified by its base name)
// is a learning record eligible for classification. Meta/policy/index docs are
// excluded.
func IsLearningFile(base string) bool {
return !metaFilenames[base]
}
// AnnotateLearning ensures a learning's YAML frontmatter carries the two
// promote-gate fields with safe defaults, returning the (possibly) rewritten
// content and whether anything changed.
//
// It is deliberately MALFORMED-TOLERANT: it operates on the `---` frontmatter
// fence textually and never parses the (possibly broken) YAML body. The corpus
// holds hand-edited and machine-extracted records with inconsistent frontmatter
// (the migration must not crash on a single junk record — council risk note).
//
// Behavior:
// - File opens with a `---` fence: any of the two keys that is absent from the
// frontmatter region is inserted just before the closing fence (or, if no
// closing fence exists, right after the opening fence). An already-present
// key (any value) is left untouched — defaults never clobber a real decision.
// - File has no opening fence: a minimal frontmatter block carrying both
// defaults is prepended.
//
// Key order is stable (sensitivity, then publishable) for deterministic diffs.
func AnnotateLearning(content string) (string, bool) {
defaults := []struct{ key, val string }{
{SensitivityField, SensitivityDefault},
{PublishableField, PublishableDefault},
}
// Normalize on \n for line work; the corpus is unix-newline.
lines := strings.Split(content, "\n")
if len(lines) == 0 || lines[0] != "---" {
// No frontmatter — prepend a minimal block with both defaults.
var b strings.Builder
b.WriteString("---\n")
for _, d := range defaults {
b.WriteString(d.key + ": " + d.val + "\n")
}
b.WriteString("---\n\n")
b.WriteString(content)
return b.String(), true
}
// Find the closing fence (first bare `---` after line 0).
closeIdx := -1
for i := 1; i < len(lines); i++ {
if lines[i] == "---" {
closeIdx = i
break
}
}
// The frontmatter region is lines[1:closeIdx] (or lines[1:] if no close).
regionEnd := closeIdx
if regionEnd == -1 {
regionEnd = len(lines)
}
present := map[string]bool{}
for i := 1; i < regionEnd; i++ {
if k, ok := frontmatterKey(lines[i]); ok {
present[k] = true
}
}
var missing []string
for _, d := range defaults {
if !present[d.key] {
missing = append(missing, d.key+": "+d.val)
}
}
if len(missing) == 0 {
return content, false
}
// Insert before the closing fence, or right after the opening fence when the
// file has no closing fence (malformed — keep the keys inside the header zone).
insertAt := closeIdx
if insertAt == -1 {
insertAt = 1
}
out := make([]string, 0, len(lines)+len(missing))
out = append(out, lines[:insertAt]...)
out = append(out, missing...)
out = append(out, lines[insertAt:]...)
return strings.Join(out, "\n"), true
}
// frontmatterKey extracts the bare YAML key from a frontmatter line, if the line
// is a top-level `key: ...` (or `key:`) mapping entry. Indented lines (block
// values, list items) return ok=false so a nested `sensitivity:` never counts.
func frontmatterKey(line string) (string, bool) {
if line == "" || line[0] == ' ' || line[0] == '\t' || line[0] == '#' || line[0] == '-' {
return "", false
}
idx := strings.IndexByte(line, ':')
if idx <= 0 {
return "", false
}
return line[:idx], true
}
+84
View File
@@ -0,0 +1,84 @@
// practices: [fail-closed-safety, wiki-knowledge-surface]
package corpus
import (
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// ClassifyResult is the report from a ClassifyDir run.
type ClassifyResult struct {
// Applied is true when changes were written to disk (false = dry run).
Applied bool `json:"applied"`
// Scanned counts learning records examined (meta docs excluded).
Scanned int `json:"scanned"`
// Changed counts records that were (or, in dry run, would be) annotated.
Changed int `json:"changed"`
// Skipped counts non-learning meta docs that were left alone.
Skipped int `json:"skipped"`
// ChangedFiles lists the relative paths that need / got annotation (sorted).
ChangedFiles []string `json:"changed_files,omitempty"`
}
// ClassifyDir walks root for `.md` learning records and ensures each carries the
// two promote-gate frontmatter defaults (sensitivity, publishable). Meta/policy
// docs (CORPUS-POLICY.md, README.md, …) are skipped. With apply=false it only
// reports what would change; with apply=true it rewrites the changed files in
// place.
//
// It is malformed-tolerant by construction — AnnotateLearning never parses the
// YAML body — so a single junk record cannot abort the migration.
func ClassifyDir(root string, apply bool) (ClassifyResult, error) {
res := ClassifyResult{Applied: apply}
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
// Skip a nested .git dir defensively (the corpus is its own repo).
if d.Name() == ".git" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(d.Name(), ".md") {
return nil
}
if !IsLearningFile(d.Name()) {
res.Skipped++
return nil
}
res.Scanned++
raw, readErr := os.ReadFile(path) // #nosec G304 -- path from the corpus dir walk
if readErr != nil {
return readErr
}
out, changed := AnnotateLearning(string(raw))
if !changed {
return nil
}
res.Changed++
rel, relErr := filepath.Rel(root, path)
if relErr != nil {
rel = path
}
res.ChangedFiles = append(res.ChangedFiles, rel)
if apply {
// Preserve the file mode; learnings are 0644.
info, statErr := d.Info()
mode := fs.FileMode(0o644)
if statErr == nil {
mode = info.Mode().Perm()
}
if writeErr := os.WriteFile(path, []byte(out), mode); writeErr != nil {
return writeErr
}
}
return nil
})
sort.Strings(res.ChangedFiles)
return res, err
}
+80
View File
@@ -0,0 +1,80 @@
package corpus
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestClassifyDir is the L2 integration test: it builds a temp corpus with the
// real on-disk shapes (fenced, no-fence, already-classified, a meta doc, a
// malformed record) and drives the whole walk → annotate → write path.
func TestClassifyDir(t *testing.T) {
root := t.TempDir()
mustWrite := func(rel, body string) {
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
mustWrite("a-fenced.md", "---\ndate: 2026-06-14\n---\nbody\n")
mustWrite("b-nofence.md", "# heading\nbody\n")
mustWrite("c-classified.md", "---\nsensitivity: public\npublishable: true\n---\nbody\n")
mustWrite("CORPUS-POLICY.md", "# policy\n") // meta — skipped
mustWrite("nested/d-malformed.md", "---\nbroken: [\n") // no close fence
mustWrite("notmarkdown.txt", "ignored\n")
// Dry run: report only, no writes.
dry, err := ClassifyDir(root, false)
if err != nil {
t.Fatalf("dry run: %v", err)
}
if dry.Applied {
t.Error("dry run reported Applied=true")
}
if dry.Scanned != 4 { // a, b, c, d — not the .txt, not the meta
t.Errorf("Scanned = %d, want 4", dry.Scanned)
}
if dry.Skipped != 1 { // CORPUS-POLICY.md
t.Errorf("Skipped = %d, want 1", dry.Skipped)
}
if dry.Changed != 3 { // a, b, d need annotation; c already classified
t.Errorf("Changed = %d, want 3 (files: %v)", dry.Changed, dry.ChangedFiles)
}
// Dry run must not have touched disk.
if got, _ := os.ReadFile(filepath.Join(root, "a-fenced.md")); string(got) != "---\ndate: 2026-06-14\n---\nbody\n" {
t.Error("dry run wrote to a-fenced.md")
}
// Apply: writes the changes.
app, err := ClassifyDir(root, true)
if err != nil {
t.Fatalf("apply: %v", err)
}
if app.Changed != 3 {
t.Errorf("apply Changed = %d, want 3", app.Changed)
}
got, _ := os.ReadFile(filepath.Join(root, "a-fenced.md"))
if !strings.Contains(string(got), "sensitivity: unknown") || !strings.Contains(string(got), "publishable: false") {
t.Errorf("a-fenced.md not annotated after apply:\n%s", got)
}
// The meta doc is untouched.
meta, _ := os.ReadFile(filepath.Join(root, "CORPUS-POLICY.md"))
if string(meta) != "# policy\n" {
t.Errorf("CORPUS-POLICY.md was modified: %q", meta)
}
// Re-run after apply is a clean no-op (idempotent migration).
again, err := ClassifyDir(root, true)
if err != nil {
t.Fatalf("rerun: %v", err)
}
if again.Changed != 0 {
t.Errorf("rerun Changed = %d, want 0 (not idempotent): %v", again.Changed, again.ChangedFiles)
}
}
+120
View File
@@ -0,0 +1,120 @@
package corpus
import (
"strings"
"testing"
)
func TestAnnotateLearning(t *testing.T) {
tests := []struct {
name string
in string
wantChanged bool
// substrings the output frontmatter must contain
wantContains []string
// substrings the output must NOT contain (e.g. duplicate keys)
wantAbsent []string
}{
{
name: "fenced record missing both fields gets both defaults",
in: "---\ndate: 2026-06-14\nstatus: reviewed\n---\n\n# Learning\nbody\n",
wantChanged: true,
wantContains: []string{"sensitivity: unknown", "publishable: false", "date: 2026-06-14"},
},
{
name: "record already carrying a real decision is left untouched",
in: "---\nsensitivity: public\npublishable: true\ndate: 2026-06-14\n---\nbody\n",
wantChanged: false,
// must not flip a real public decision back to the default
wantAbsent: []string{"sensitivity: unknown", "publishable: false"},
},
{
name: "partial — only the missing field is added",
in: "---\nsensitivity: private\ndate: 2026-06-14\n---\nbody\n",
wantChanged: true,
wantContains: []string{"sensitivity: private", "publishable: false"},
wantAbsent: []string{"sensitivity: unknown"},
},
{
name: "no frontmatter fence — minimal block is prepended",
in: "# Orchestration spike\n\nSource session: foo.\n",
wantChanged: true,
wantContains: []string{"---\nsensitivity: unknown\npublishable: false\n---", "# Orchestration spike"},
},
{
name: "malformed — opening fence but no closing fence still lands keys in header zone",
in: "---\ndate: 2026-06-14\nbroken yaml here with no close fence\n",
wantChanged: true,
wantContains: []string{"sensitivity: unknown", "publishable: false"},
},
{
name: "nested sensitivity key under a block does not count as present",
in: "---\ndate: 2026-06-14\nmeta:\n sensitivity: high\n---\nbody\n",
wantChanged: true,
// the top-level field must still be added despite the nested one
wantContains: []string{"date: 2026-06-14"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, changed := AnnotateLearning(tt.in)
if changed != tt.wantChanged {
t.Fatalf("changed = %v, want %v\noutput:\n%s", changed, tt.wantChanged, got)
}
for _, sub := range tt.wantContains {
if !strings.Contains(got, sub) {
t.Errorf("output missing %q\ngot:\n%s", sub, got)
}
}
for _, sub := range tt.wantAbsent {
if strings.Contains(got, sub) {
t.Errorf("output unexpectedly contains %q\ngot:\n%s", sub, got)
}
}
})
}
}
// TestAnnotateLearning_Idempotent guards the migration's re-run safety: applying
// twice must be a no-op the second time (round-trips the real first-pass output).
func TestAnnotateLearning_Idempotent(t *testing.T) {
in := "---\ndate: 2026-06-14\n---\nbody\n"
once, changed1 := AnnotateLearning(in)
if !changed1 {
t.Fatal("first pass should change an unclassified record")
}
twice, changed2 := AnnotateLearning(once)
if changed2 {
t.Errorf("second pass changed an already-classified record (not idempotent)\nfirst:\n%s", once)
}
if once != twice {
t.Errorf("re-annotation altered content:\nfirst:\n%s\nsecond:\n%s", once, twice)
}
// exactly one of each key
if n := strings.Count(twice, "sensitivity: "); n != 1 {
t.Errorf("sensitivity key count = %d, want 1", n)
}
if n := strings.Count(twice, "publishable: "); n != 1 {
t.Errorf("publishable key count = %d, want 1", n)
}
}
func TestIsLearningFile(t *testing.T) {
learnings := []string{"2026-06-14-foo.md", "research/bar.md"}
meta := []string{"CORPUS-POLICY.md", "README.md", "MEMORY.md", "INDEX.md"}
for _, f := range learnings {
base := f
if i := strings.LastIndexByte(f, '/'); i >= 0 {
base = f[i+1:]
}
if !IsLearningFile(base) {
t.Errorf("IsLearningFile(%q) = false, want true", base)
}
}
for _, f := range meta {
if IsLearningFile(f) {
t.Errorf("IsLearningFile(%q) = true, want false (meta doc)", f)
}
}
}
+7
View File
@@ -337,6 +337,13 @@
"kind": "leaf",
"reason": "Covered by release smoke tests, direct command tests, or command handler tests."
},
{
"category": "public-tested",
"command": "corpus classify",
"coverage_status": "covered",
"kind": "leaf",
"reason": "Covered by release smoke tests, direct command tests, or command handler tests."
},
{
"category": "public-tested",
"command": "corpus fitness",
+1
View File
@@ -52,6 +52,7 @@
| `ao contradict` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. |
| `ao converge` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. |
| `ao corpus capture` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. |
| `ao corpus classify` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. |
| `ao corpus fitness` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. |
| `ao corpus inject` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. |
| `ao corpus restore` | `public-stateful-fixture-needed` | `allowlisted` | Restores corpus snapshots and needs a disposable corpus fixture. |
+69
View File
@@ -0,0 +1,69 @@
# Corpus learning seam — the field-level public/private boundary
> **Status:** contract · **Epic:** ag-k7tq9 (corpus private/public separation) · **Slice:** S3 (ag-2srq1)
> **Authority:** the unanimous cross-family council verdict
> [`.agents/council/2026-06-15-corpus-private-public-seam-verdict.md`](../../.agents/council/2026-06-15-corpus-private-public-seam-verdict.md).
> **Schema:** [`schemas/learning.v1.schema.json`](../../schemas/learning.v1.schema.json).
> **Migration:** `ao corpus classify` (`cli/internal/corpus/classify.go`).
The corpus (`boshu2/agentops-corpus`, mounted at `.agents/learnings/`) is **lossless
and private by default**. Evidence, provenance, and source pointers live there
forever. Sensitivity is a **publish property, not a capture property**: nothing is
decided at mine time. The seam is enforced at **promote time**, **field-level**,
with the source session's sensitivity as a **ceiling**.
This contract defines which fields of a learning record may cross that seam.
## The two promote-gate fields
Every learning record carries two frontmatter fields that gate promotion. Both
default to the most conservative value (`ao corpus classify` backfills them):
| Field | Type | Default | Meaning |
|---|---|---|---|
| `sensitivity` | `unknown` \| `private` \| `public` | `unknown` | The ceiling. `unknown` = un-triaged; `private` = tainted, never publishes; `public` = abstracted, cleared for the wiki. |
| `publishable` | boolean | `false` | The allowlist flag. `true` only after the lesson is abstracted **and** passes the leak scanner. |
**Promote rule (allowlist, fail-closed):** a record may be promoted to `docs/wiki/`
only when `sensitivity == "public"` **AND** `publishable == true`. Default excludes.
Inclusion is earned, never assumed. A single fat-finger cannot publish the corpus.
## The field boundary — what crosses, what never does
| Field(s) | Class | Crosses the seam? |
|---|---|---|
| the abstracted **lesson** body (the markdown after the frontmatter, once generalized) | publishable | **yes** — and only this |
| `source_session` | private provenance | **never** |
| `source_bead` / bead ids | private provenance | **never** |
| evidence paths (`Evidence:` refs, file:line citations into private repos) | private evidence | **never** |
| any fleet / client / peer-agent / private-namespace / mythology / brand reference | private (leak markers) | **never** — hard-fail in the scanner |
Only the **lesson** crosses. `evidence` / `provenance` / `source_session` stay in the
private corpus — which is also what keeps the provenance graph private (dovetails with
mesh ag-5qltf). Mine-time labels (`tier`, `maturity`, `category`) are **triage only**,
**not** a declassification boundary.
## Defense in depth (all fail-closed)
1. **Physical separation** — the corpus is a separate private repo; no
`.gitignore`-negation single point of failure.
2. **Allowlist, not blocklist**`ao corpus publish` (S6) emits only records that
pass the promote rule above. Default = exclude.
3. **Leak scan on rendered output**`ao corpus scan` / `cli/internal/corpusscan`
(S4): any fleet/client/peer/PII/brand/landmine marker hit = hard FAIL, **never
auto-redact**.
4. **Pre-push guard + CI re-scan** (S1/S7) — reject staged raw corpus paths; re-scan
`wiki/**` on push, fail closed.
## Migration
`ao corpus classify <dir>` backfills the two defaults onto every learning record,
**malformed-tolerant** (operates on the `---` frontmatter fence textually; never parses
the YAML body, so one junk record cannot abort the run) and **idempotent** (an existing
real decision is never overwritten). Meta docs (`CORPUS-POLICY.md`, `README.md`, …) are
skipped. Dry-run by default; `--apply` writes.
```bash
ao corpus classify .agents/learnings # dry run — report only
ao corpus classify .agents/learnings --apply # write the safe defaults
```
+1
View File
@@ -282,6 +282,7 @@ Bridge / framing docs:
- [Pawls — the one-way doors](contracts/pawls.md) — The ratchet's static map: the short list of irreversible actions (mutate-shared-trunk · delete · external-send/shared-state-mutation · schema/contract change · credential/authority change · spend) where the cross-family gate fires; everything else runs as ungated chaos
- [Operating Discipline (D1D16)](doctrine/operating-discipline.md) — The general, substrate-neutral fleet-operating rules (admission-first · author≠judge · fail-closed · evidence-bound · single-writer · typed transitions) folded from the mt-olympus triangulated kernel; each rule marked embodied-in-gate (cited to pawls.md / pawl-verdict.sh / reconcile-pr.sh), advisory doctrine, or dropped-as-cathedral
- [Lesson Format](contracts/lesson-format.md) — Schema for `.agents/learnings/` entries with frontmatter (id/severity/trigger/verifiable/rule/falsified_by/practice/related) and graduation path (unassigned → proposed → accepted → encoded)
- [Corpus Learning Seam](contracts/corpus-learning-seam.md) — Field-level public/private boundary for learning records (epic ag-k7tq9 S3): the `sensitivity` + `publishable` promote-gate fields, what crosses the seam (the abstracted lesson) vs what never does (evidence/provenance/source_session), and the `ao corpus classify` migration; cites the cross-family council verdict
- [bd remember Migration Manifest](contracts/bd-remember-migration-manifest.md) — Lineage-preserving manifest contract for classifying `bd remember` notes into bead-scoped, pull-learning, or discard dispositions before migration
- [Bounded Contexts (yaml)](contracts/bounded-contexts.yaml) — Canonical BC1-BC5 definitions (id/name/responsibility/ports/center-of-gravity); registry doc prose must match this yaml (drift-checked by `scripts/check-bounded-contexts-drift.sh`, soc-zxia.2)
- [add-validate-job scaffolder](https://github.com/boshu2/agentops/blob/main/scripts/add-validate-job.sh) — CI integration scaffolder; emits all 5 touch-points (workflow + summary needs + summary echo + pre-push + bats stub + AGENTS table) atomically when adding a new `validate-*` job (soc-3oij)
@@ -41,7 +41,7 @@
},
"expectations": [
{"type": "exit_code", "value": 0},
{"type": "stdout_contains", "value": "cli-command-headings: top=88 sub=215 all=303"},
{"type": "stdout_contains", "value": "cli-command-headings: top=88 sub=216 all=304"},
{"type": "stdout_contains", "value": "cli-help-matrix-ok"}
],
"dimensions": ["correctness", "runtime_compatibility", "artifact_quality"],
@@ -17,7 +17,7 @@ top_count="$(rg -c '^### `ao ' "$DOCS_PATH")"
sub_count="$(rg -c '^#### `ao ' "$DOCS_PATH")"
all_count="$(rg -c '^#{3,4} `ao ' "$DOCS_PATH")"
if [[ "$top_count" != "88" || "$sub_count" != "215" || "$all_count" != "303" ]]; then
if [[ "$top_count" != "88" || "$sub_count" != "216" || "$all_count" != "304" ]]; then
printf 'unexpected command heading counts: top=%s sub=%s all=%s\n' "$top_count" "$sub_count" "$all_count" >&2
exit 1
fi
@@ -25,7 +25,7 @@ fi
# shellcheck disable=SC2016 # literal backticks delimit generated Markdown command headings.
mapfile -t commands < <(rg '^#{3,4} `ao ' "$DOCS_PATH" | sed -E 's/^.*`([^`]+)`.*/\1/')
if [[ "${#commands[@]}" -ne 303 ]]; then
if [[ "${#commands[@]}" -ne 304 ]]; then
printf 'unexpected command matrix size: %s\n' "${#commands[@]}" >&2
exit 1
fi
+1 -1
View File
@@ -1,6 +1,6 @@
{
"schema_version": 2,
"generated_at": "2026-06-15T16:17:48Z",
"generated_at": "2026-06-15T20:01:15Z",
"summary": {
"skills": 72,
"hooks": 0,
+11
View File
@@ -63,6 +63,17 @@
"default": "pull",
"description": "Blast-radius tier, orthogonal to maturity: bead=per-bead context, pull=queried on demand (default), always=auto-injected at session bootstrap (computed from maturity==established INTERSECT canon, never author-set)."
},
"sensitivity": {
"type": "string",
"enum": ["unknown", "private", "public"],
"default": "unknown",
"description": "Promote-gate CEILING — a publish property, NOT a capture property. unknown=un-triaged (default); private=tainted, never crosses the seam; public=abstracted lesson cleared for the wiki. Only sensitivity==public AND publishable==true may be promoted to docs/wiki (allowlist, fail-closed; default excludes). Per council verdict .agents/council/2026-06-15-corpus-private-public-seam-verdict.md; field boundary in docs/contracts/corpus-learning-seam.md."
},
"publishable": {
"type": "boolean",
"default": false,
"description": "Promotion allowlist flag. false by default; set true ONLY after the lesson body is abstracted and passes the corpusscan leak detector (cli/internal/corpusscan). Inclusion is earned; default excludes."
},
"schema_version": {
"type": "integer",
"const": 1