checkpoint: multi-repo Gitea ingestion, per-project repos, rotation anchor_sha fix

Pre-existing uncommitted work found at session start: rotation now emits
anchor_sha on TaskReleased (previously silently dropped by store.Append
validation), multi-repo Gitea provider support, per-project git worktree
roots, and associated test coverage. Committing as a checkpoint before
starting remediation work tracked in AUDIT.md.
This commit is contained in:
kami
2026-07-27 18:15:02 +04:00
parent 325c684eb0
commit ce6f02f9e6
31 changed files with 2717 additions and 320 deletions
+14 -1
View File
@@ -17,7 +17,11 @@ var ErrConflict = errors.New("task version conflict")
var ErrNotFound = errors.New("task not found")
var ErrInvalid = errors.New("invalid event")
const CurrentEventSchema = 1
// CurrentEventSchema is 2: schema 2 requires every event to declare its
// authorizing Surface (see ValidateEvent), enforced at the store append
// boundary. Schema 1 events already on disk replay unchanged — tolerant
// reader, not upcast (spec open question #2).
const CurrentEventSchema = 2
type TaskState string
@@ -63,6 +67,12 @@ type Event struct {
Version int `json:"version"`
At time.Time `json:"at"`
Payload json.RawMessage `json:"payload"`
// Surface identifies the bus capability the emitter is authorized under
// (see internal/authz). It is required on every event so authorization is
// enforced once, at the store append boundary, regardless of whether the
// emitter reached the store over HTTP, from the router, from a harness
// adapter, or from a provider.
Surface string `json:"surface"`
}
func Hash(v []byte) string { h := sha256.Sum256(v); return hex.EncodeToString(h[:]) }
@@ -82,6 +92,9 @@ func ValidateEvent(e Event) error {
if e.SchemaVersion > CurrentEventSchema || e.Type == "" || e.TaskID == "" || len(e.Payload) == 0 || len(e.Payload) > 64*1024 {
return ErrInvalid
}
if e.SchemaVersion >= 2 && strings.TrimSpace(e.Surface) == "" {
return fmt.Errorf("%w: surface required", ErrInvalid)
}
allowed := map[string]bool{"TaskCreated": true, "TaskLeased": true, "TaskReleased": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "QuotaReported": true, "StandupAdvisory": true}
if !allowed[e.Type] {
return fmt.Errorf("%w: unknown type %q", ErrInvalid, e.Type)
+149
View File
@@ -0,0 +1,149 @@
package domain
import (
"encoding/json"
"testing"
)
// eventTypesUnderTest is the full lifecycle vocabulary the spec (§9 item 5)
// requires validators for; a validator that panics or accepts garbage for any
// of these on adversarial input is a defect regardless of whether real
// producers happen to send well-formed payloads.
var eventTypesUnderTest = []string{
"TaskCreated", "TaskLeased", "TaskReleased", "TaskCompleted", "TaskFailed",
"TaskBlocked", "ApprovalRequested", "ApprovalGranted", "ApprovalDenied",
"TaskAmended", "QuotaReported", "StandupAdvisory",
}
// FuzzValidatePayload feeds arbitrary JSON object shapes at every known event
// type's validator and requires it to either return a typed ErrInvalid or
// accept — never panic. The seed corpus below exercises adjacent-to-valid and
// wildly-malformed shapes (wrong types, huge strings, nested structures,
// nulls, NaN-adjacent floats via JSON) for each type.
func FuzzValidatePayload(f *testing.F) {
seeds := []string{
`{}`,
`null`,
`{"source":"jsonl","external_id":"1","project":"p"}`,
`{"source":123,"external_id":null,"project":[]}`,
`{"harness_id":"h1","ttl":60,"expected_version":1}`,
`{"harness_id":"h1","until_ns":1e300,"expected_version":1.5}`,
`{"handoff_ref":"` + fakeHash() + `","anchor_sha":"` + fakeSHA() + `"}`,
`{"handoff_ref":123,"anchor_sha":true}`,
`{"report_ref":"` + fakeHash() + `","receipt":{"harness_id":"h1","consumed":1}}`,
`{"report_ref":"","receipt":{}}`,
`{"reason":"x"}`,
`{"reason":123}`,
`{"blocker":"x","handoff_ref":"` + fakeHash() + `"}`,
`{"blocker":""}`,
`{"amendment":"x"}`,
`{"subject_ref":"x","options":["a","b"]}`,
`{"subject_ref":123,"options":null}`,
`{"harness_id":"h1","consumed":90.5}`,
`{"harness_id":"h1","consumed":-1}`,
`{"harness_id":"h1","consumed":"a lot"}`,
`{"items":["standup line"]}`,
`{"items":null}`,
`{"a":{"b":{"c":{"d":[1,2,3,{"e":"f"}]}}}}`,
`{"x":` + hugeString() + `}`,
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, raw string) {
var p map[string]any
if err := json.Unmarshal([]byte(raw), &p); err != nil {
return // not a JSON object; ValidateEvent itself rejects non-objects before reaching ValidatePayload
}
for _, typ := range eventTypesUnderTest {
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("ValidatePayload(%q, %s) panicked: %v", typ, raw, r)
}
}()
err := ValidatePayload(typ, p)
if err != nil && err != ErrInvalid {
// must still be a typed validation error, wrapping ErrInvalid
if !isInvalid(err) {
t.Fatalf("ValidatePayload(%q, %s) returned non-typed error: %v", typ, raw, err)
}
}
}()
}
})
}
// FuzzValidateEvent exercises the full envelope path (schema version, surface
// requirement, type allow-list, payload size/parseability) with arbitrary
// type names, surfaces, and payload bytes, proving no combination panics.
func FuzzValidateEvent(f *testing.F) {
f.Add("TaskCreated", "system", 2, []byte(`{"source":"jsonl","external_id":"1","project":"p"}`))
f.Add("", "", 0, []byte(``))
f.Add("Bogus", "system", 2, []byte(`{}`))
f.Add("TaskCreated", "", 2, []byte(`{"source":"jsonl","external_id":"1","project":"p"}`))
f.Add("TaskCreated", "system", 1, []byte(`not json`))
f.Add("QuotaReported", "system", 2, []byte(`null`))
f.Add("TaskCompleted", "system", 99, []byte(`{"report_ref":"x","receipt":{}}`))
f.Fuzz(func(t *testing.T, typ, surface string, schema int, payload []byte) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("ValidateEvent panicked: type=%q surface=%q schema=%d payload=%q: %v", typ, surface, schema, payload, r)
}
}()
e := Event{
SchemaVersion: schema,
Type: typ,
TaskID: "t1",
Payload: payload,
Surface: surface,
}
_ = ValidateEvent(e)
})
}
func isInvalid(err error) bool {
for e := err; e != nil; {
if e == ErrInvalid {
return true
}
u, ok := e.(interface{ Unwrap() error })
if !ok {
return false
}
e = u.Unwrap()
}
return false
}
func fakeHash() string {
b := make([]byte, 32)
for i := range b {
b[i] = byte(i)
}
s := ""
for _, c := range b {
s += string("0123456789abcdef"[c>>4]) + string("0123456789abcdef"[c&0xf])
}
return s
}
func fakeSHA() string {
s := ""
for i := 0; i < 40; i++ {
s += "a"
}
return s
}
func hugeString() string {
b, _ := json.Marshal(make([]byte, 0))
_ = b
s := `"`
for i := 0; i < 5000; i++ {
s += "x"
}
return s + `"`
}