Separate bounded sibling ingest from search deadlines
Co-Authored-By: Codex <codex@openai.com>
6 files changed,
+314
−2
docs/superpowers/plans/2026-09-07-sibling-ingest-budget.md+69 −0internal/memoria/custody.go+2 −0internal/memoria/ingest_budget_test.go+77 −0internal/memoria/sibling/feed.go+7 −2internal/memoria/sibling/ingest_deadline_test.go+154 −0internal/memoria/siblingsync.go+5 −0
diff --git a/docs/superpowers/plans/2026-09-07-sibling-ingest-budget.md b/docs/superpowers/plans/2026-09-07-sibling-ingest-budget.md| new file mode 100644 |
| index 0000000..068ab76 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-07-sibling-ingest-budget.md |
| @@ -0,0 +1,69 @@ |
| +# Separate ingest and search deadlines |
| + |
| +Status: DRAFT implementation, unreviewed. No merge or deployment. |
| +LLM implementation plan, 2026-09-07. Baseline 6a1e05b (approved dependency pin), |
| +branch fix/s1-deadline, private Memoria discussion 5. Custody regression branch |
| +e4b178f remains separate. |
| + |
| +## Settled behavior and evidence |
| + |
| +Memoria §6.5 sets 800ms per sibling for the S2 search-page authorization round. |
| +It expects asker-less S1 wake polling to spend cold-start latency outside a |
| +reader's request. Feed and Content both currently apply the S2 Deadline constant. |
| +A real HTTP fixture returning valid S1 JSON or S3 content after 1100ms fails at |
| +800ms with a caller deadline of 3s. TestIngestCanFinishAfterTheSearchDeadline |
| +records both failures. |
| + |
| +TestIngestRetainsCallerCancellation passes for S1/S3 both before headers and |
| +while reading the response body. The server observes cancellation; returned |
| +errors retain context.Canceled without URL, item ID or cursor. Evidence is |
| +/tmp/oficina-memoria-s1-review/baseline.log. |
| + |
| +No explicit ingest timeout number exists in the binding spec. The 5s budget in |
| +§7.3 concerns loading the sealed index, not sibling ingestion. The suite HTTP |
| +client at 1ac4323 has no Client.Timeout. WarmSiblings uses context.WithoutCancel |
| +because polling outlives its initiating request. Removing the wrappers without |
| +a replacement can hold a wake forever: maxFeedPages bounds pages, not time. |
| + |
| +## Proposed implementation, pending design review |
| + |
| +Keep S2 Deadline=800ms and its concurrency, UNKNOWN and cancellation behavior. |
| +Introduce a separate 45s ceiling for each S1/S3 request, covering connection, |
| +headers and the bounded response body. Keep the caller context, so an earlier |
| +deadline or explicit cancellation wins. Current platform source df7d1d25 gives wakeWaitBound=30s, |
| +spawnHealthTimeout=30s, backhaulDialTimeout=5s and backhaulHeaderTimeout=30s. |
| +45s allows that wake/header bound plus connection and body-transfer margin; |
| +it is an engineering ceiling, not a measured cold-wake capacity claim. Measure actual cold sibling latency at canary before shipping; |
| +if insufficient, change the reviewed cap with evidence. |
| + |
| +Bound the whole concurrent Warm call and each standalone Store.Poll to 120s, inherited by every network and page |
| +operation. Per-request ceilings alone allow 50 pages times many content calls |
| +to retain a wake excessively. Keep maxFeedPages as the independent loop cap. |
| +The wake stays independent of the first browser request but gains a finite poll |
| +lifetime. No periodic wake timer is added. |
| + |
| +On timeout use the existing error path, never an empty feed/body or a successful |
| +PolledAt. Keep cursor advancement after a fully applied page. Completed earlier |
| +pages may already have advanced; partial pages retry idempotently next poll. |
| +Do not change S3 404 handling, deletion rules, seals, notices or authorization. |
| +Post-commit derived-index refresh retains its existing WithoutCancel consistency |
| +rule; its obligation differs from a network timeout. No payload/URL logging. |
| + |
| +## Required verification |
| + |
| +- The real HTTP 1100ms feed/content regressions become green. |
| +- Caller cancellation and shorter deadlines stop header/body waits. |
| +- Successive slow content calls cannot reset the whole-poll budget; an incomplete |
| + page leaves its cursor and PolledAt unchanged. |
| +- A stalled sibling cannot block another sibling's independent wake poll. |
| +- Existing S2 literal 800ms and concurrent-page regressions pass unchanged. |
| +- Inspect effective transport deadlines to prove finite production ceilings |
| + without waiting 45/120s in tests; pair with real HTTP timing/cancellation cases. |
| +- Mutation-prove recoupling Feed/Content to S2 Deadline, discarding cancellation, |
| + and resetting the total budget per row. Record observed mutations and dates |
| + in test comments when implementation lands. Run mutations only in this isolated worktree, restore before committing. |
| + |
| +Root authorized this bounded technical choice as draft implementation; Fable |
| +review of design and whole branch is required before approval or integration. No review call |
| +before the 01:50 Europe/Dublin reset and root allocation. No merge/deployment; |
| +the full release still needs custody fixes, rollback controls and other findings. |
diff --git a/internal/memoria/custody.go b/internal/memoria/custody.go| index bebba72..b259c6b 100644 |
| --- a/internal/memoria/custody.go |
| +++ b/internal/memoria/custody.go |
| @@ -108,6 +108,8 @@ func (s *Siblings) Rule(ctx context.Context, asker suite.Subject, cands []siblin |
| // on a wake — the reader learns about it from the withholding notice on |
| // the page, which is where §6.5 puts it. |
| func (s *Siblings) Warm(ctx context.Context, store *Store, logger *slog.Logger) { |
| + ctx, cancel := context.WithTimeout(ctx, siblingPollDeadline) |
| + defer cancel() |
| var wg sync.WaitGroup |
| for _, app := range s.byName { |
| wg.Add(1) |
diff --git a/internal/memoria/ingest_budget_test.go b/internal/memoria/ingest_budget_test.go| new file mode 100644 |
| index 0000000..74d36a7 |
| --- /dev/null |
| +++ b/internal/memoria/ingest_budget_test.go |
| @@ -0,0 +1,77 @@ |
| +package memoria |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/memoria/internal/memoria/sibling" |
| + "amadan.net/oficina/suite" |
| + "gorm.io/gorm" |
| +) |
| + |
| +// Mutation-tested 2026-09-07: removing Poll's timeout failed at the real |
| +// cursor query, whose context no longer had a deadline. |
| +func TestPollEstablishesFiniteBudgetBeforeReadingItsCursor(t *testing.T) { |
| + st := storeForTest(t) |
| + var observed atomic.Bool |
| + if err := st.db.G.Callback().Query().Before("gorm:query").Register("test:poll_deadline", func(tx *gorm.DB) { |
| + if observed.Swap(true) { |
| + return |
| + } |
| + d, ok := tx.Statement.Context.Deadline() |
| + if !ok || time.Until(d) > 120*time.Second || time.Until(d) < 119*time.Second { |
| + t.Errorf("poll deadline present=%v remaining=%v", ok, time.Until(d)) |
| + } |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + f := &feedServer{t: t, pages: map[string]suite.Feed{"": {Cursor: "end", Rows: []suite.FeedRow{}}, "end": {Cursor: "end", Rows: []suite.FeedRow{}}}} |
| + sib, client := f.start(t) |
| + if _, err := st.Poll(context.Background(), client, sib); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !observed.Load() { |
| + t.Fatal("cursor query was not observed") |
| + } |
| +} |
| + |
| +func TestPollDeadlineCoversSuccessiveContentRequests(t *testing.T) { |
| + st := storeForTest(t) |
| + var calls atomic.Int64 |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path == suite.RouteS1 { |
| + _ = json.NewEncoder(w).Encode(suite.Feed{Cursor: "next", Rows: []suite.FeedRow{row("first", 1, false), row("second", 1, false)}}) |
| + return |
| + } |
| + calls.Add(1) |
| + select { |
| + case <-time.After(180 * time.Millisecond): |
| + _, _ = w.Write([]byte("synthetic body")) |
| + case <-r.Context().Done(): |
| + } |
| + })) |
| + defer srv.Close() |
| + sib := sibling.Sibling{Name: "Docs", BaseURL: srv.URL, Token: "synthetic"} |
| + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) |
| + defer cancel() |
| + res, err := st.Poll(ctx, sibling.NewForTest(), sib) |
| + if !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("poll deadline error=%v", err) |
| + } |
| + if calls.Load() != 2 || res.Stored != 1 { |
| + t.Fatalf("requests=%d stored=%d", calls.Load(), res.Stored) |
| + } |
| + cursor, err := st.cursor(context.Background(), sib.Name) |
| + if err != nil || cursor != "" { |
| + t.Errorf("partial page advanced cursor: err=%v", err) |
| + } |
| + if !polledAt(t, st, sib.Name).IsZero() { |
| + t.Error("partial page was marked current") |
| + } |
| +} |
diff --git a/internal/memoria/sibling/feed.go b/internal/memoria/sibling/feed.go| index a6589b5..fa0f490 100644 |
| --- a/internal/memoria/sibling/feed.go |
| +++ b/internal/memoria/sibling/feed.go |
| @@ -7,10 +7,15 @@ import ( |
| "io" |
| "net/http" |
| "net/url" |
| + "time" |
| "amadan.net/oficina/suite" |
| ) |
| +// Ingest allows the platform's 30s wake/header wait plus transfer margin. |
| +// This is a finite engineering bound; cold-wake latency still needs measurement. |
| +const ingestDeadline = 45 * time.Second |
| + |
| // maxFeedResponse and maxContentResponse cap what a sibling can make |
| // memoria hold in memory on one call. |
| // |
| @@ -40,7 +45,7 @@ const ( |
| // comes back **only on success**: advancing past a page nobody read is how |
| // a consumer skips changes it will never be offered again. |
| func (c *Client) Feed(ctx context.Context, s Sibling, cursor string) (suite.Feed, error) { |
| - ctx, cancel := context.WithTimeout(ctx, Deadline) |
| + ctx, cancel := context.WithTimeout(ctx, ingestDeadline) |
| defer cancel() |
| u := s.BaseURL + suite.RouteS1 |
| @@ -88,7 +93,7 @@ func (c *Client) Feed(ctx context.Context, s Sibling, cursor string) (suite.Feed |
| // leaking, and folding that into ErrNotAvailable would hide the leak from |
| // the only person who could fix it. |
| func (c *Client) Content(ctx context.Context, s Sibling, itemID string, revision int64) ([]byte, error) { |
| - ctx, cancel := context.WithTimeout(ctx, Deadline) |
| + ctx, cancel := context.WithTimeout(ctx, ingestDeadline) |
| defer cancel() |
| // **The item id is a PATH segment, not a query parameter.** The |
diff --git a/internal/memoria/sibling/ingest_deadline_test.go b/internal/memoria/sibling/ingest_deadline_test.go| new file mode 100644 |
| index 0000000..ef0186c |
| --- /dev/null |
| +++ b/internal/memoria/sibling/ingest_deadline_test.go |
| @@ -0,0 +1,154 @@ |
| +package sibling |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "io" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func ingestCall(ctx context.Context, c *Client, s Sibling, content bool) error { |
| + if content { |
| + body, err := c.Content(ctx, s, "synthetic-item", 1) |
| + if err == nil && string(body) != "synthetic body" { |
| + return errors.New("unexpected body") |
| + } |
| + return err |
| + } |
| + feed, err := c.Feed(ctx, s, "synthetic-cursor") |
| + if err == nil && (feed.Cursor != "next" || len(feed.Rows) != 0) { |
| + return errors.New("unexpected feed") |
| + } |
| + return err |
| +} |
| + |
| +func ingestResponse(content bool) string { |
| + if content { |
| + return "synthetic body" |
| + } |
| + return `{"cursor":"next","rows":[]}` |
| +} |
| + |
| +// Mutation-tested 2026-09-07: recoupling Feed and Content separately to |
| +// the 800ms Deadline made the corresponding real HTTP case fail. |
| +func TestIngestCanFinishAfterTheSearchDeadline(t *testing.T) { |
| + for _, content := range []bool{false, true} { |
| + name := "S1" |
| + if content { |
| + name = "S3" |
| + } |
| + t.Run(name, func(t *testing.T) { |
| + s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| + timer := time.NewTimer(1100 * time.Millisecond) |
| + defer timer.Stop() |
| + select { |
| + case <-timer.C: |
| + _, _ = w.Write([]byte(ingestResponse(content))) |
| + case <-r.Context().Done(): |
| + } |
| + }) |
| + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) |
| + defer cancel() |
| + if err := ingestCall(ctx, newTestClient(), s, content); err != nil { |
| + t.Fatalf("valid ingest response within caller budget failed: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Mutation-tested 2026-09-07: discarding the request context failed all |
| +// four cases, both in the client and in the server cancellation observer. |
| +func TestIngestRetainsCallerCancellation(t *testing.T) { |
| + for _, content := range []bool{false, true} { |
| + for _, bodyStarted := range []bool{false, true} { |
| + name := "S1" |
| + if content { |
| + name = "S3" |
| + } |
| + if bodyStarted { |
| + name += "-body" |
| + } else { |
| + name += "-headers" |
| + } |
| + t.Run(name, func(t *testing.T) { |
| + started := make(chan struct{}) |
| + observed := make(chan struct{}) |
| + s := serve(t, func(w http.ResponseWriter, r *http.Request) { |
| + if bodyStarted { |
| + _, _ = w.Write([]byte("{")) |
| + w.(http.Flusher).Flush() |
| + } |
| + close(started) |
| + select { |
| + case <-r.Context().Done(): |
| + close(observed) |
| + case <-time.After(2 * time.Second): |
| + } |
| + }) |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + done := make(chan error, 1) |
| + go func() { done <- ingestCall(ctx, newTestClient(), s, content) }() |
| + select { |
| + case <-started: |
| + case <-time.After(time.Second): |
| + t.Fatal("request did not start") |
| + } |
| + cancel() |
| + select { |
| + case err := <-done: |
| + if !errors.Is(err, context.Canceled) { |
| + t.Errorf("cancellation returned %v", err) |
| + } |
| + if err != nil && (strings.Contains(err.Error(), s.BaseURL) || strings.Contains(err.Error(), "synthetic-item") || strings.Contains(err.Error(), "synthetic-cursor")) { |
| + t.Error("error disclosed request coordinates") |
| + } |
| + case <-time.After(500 * time.Millisecond): |
| + t.Error("ingest ignored caller cancellation") |
| + } |
| + select { |
| + case <-observed: |
| + case <-time.After(500 * time.Millisecond): |
| + t.Error("server did not observe cancellation") |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestIngestRequestBudgetIsFiniteAndRespectsEarlierDeadline(t *testing.T) { |
| + for _, content := range []bool{false, true} { |
| + for _, earlier := range []bool{false, true} { |
| + ctx := context.Background() |
| + want := 45 * time.Second |
| + cancel := func() {} |
| + if earlier { |
| + want = 2 * time.Second |
| + ctx, cancel = context.WithTimeout(ctx, want) |
| + } |
| + var requestContext context.Context |
| + client := &Client{http: &http.Client{Transport: ingestRoundTrip(func(r *http.Request) (*http.Response, error) { |
| + requestContext = r.Context() |
| + deadline, ok := requestContext.Deadline() |
| + if !ok || time.Until(deadline) > want || time.Until(deadline) < want-time.Second { |
| + t.Errorf("request budget: deadline present=%v remaining=%v want=%v", ok, time.Until(deadline), want) |
| + } |
| + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(ingestResponse(content))), Header: make(http.Header)}, nil |
| + })}} |
| + if err := ingestCall(ctx, client, Sibling{Name: "Docs", BaseURL: "https://test.docs.oficina.works"}, content); err != nil { |
| + t.Fatal(err) |
| + } |
| + if requestContext.Err() != context.Canceled { |
| + t.Error("completed request retained its timeout context") |
| + } |
| + cancel() |
| + } |
| + } |
| +} |
| + |
| +type ingestRoundTrip func(*http.Request) (*http.Response, error) |
| + |
| +func (f ingestRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
diff --git a/internal/memoria/siblingsync.go b/internal/memoria/siblingsync.go| index f2833d6..1b7af1e 100644 |
| --- a/internal/memoria/siblingsync.go |
| +++ b/internal/memoria/siblingsync.go |
| @@ -23,6 +23,9 @@ import ( |
| // stopped looking" is the difference this whole file is about. |
| const maxFeedPages = 50 |
| +// One wake cannot spend a fresh request budget on every row indefinitely. |
| +const siblingPollDeadline = 2 * time.Minute |
| + |
| // PollResult is what one poll did. Counts only: no item id, no title, no |
| // body, so it can go in a log line or on the sources page (AGENTS.md). |
| type PollResult struct { |
| @@ -72,6 +75,8 @@ type PollResult struct { |
| // no-op. The expensive failure is skipping a row nobody will mention |
| // again. |
| func (s *Store) Poll(ctx context.Context, c *sibling.Client, sib sibling.Sibling) (PollResult, error) { |
| + ctx, cancel := context.WithTimeout(ctx, siblingPollDeadline) |
| + defer cancel() |
| var res PollResult |
| cursor, err := s.cursor(ctx, sib.Name) |
| if err != nil { |