fix(harbor): fail-closed split access, budget/tier reconciliation, off-loop ledger flush [fixes 1/4 core]#7
Conversation
…f-loop ledger flush Core trust-boundary hardening for the eval engine (review findings on PR #3): - engine: explicit, fail-closed no_access gate in evaluate(). Resolve the split's tier (unlisted defaults to no_access) and reject no_access for non-admin callers before the budget ledger, instead of relying implicitly on the split being absent from the ledger. Adds resolve_split_access() in core.dataset.base (no vero.harbor import) as the single fail-closed resolver. - policy: reconcile budgets and split access at init. Auto-tier every agent-budgeted split to non_viewable when unlisted (evaluable, labels hidden), then reject any budgeted split explicitly tiered viewable or no_access. Keeps the train_budget convenience path working while closing the leak. - budget: move the durable ledger flush off the event loop (asyncio.to_thread) while keeping it under the reservation lock, so the sync write no longer blocks the loop and concurrent flushes cannot race the temp file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
a90da6c to
db7d9ed
Compare
Should split access be a dataset property with a real-time check?Yes, and this PR lands the minimal version of exactly that. The three access-related review findings are three symptoms of one root cause: access had no single owner and no real-time gate, so it was approximated by two proxies (an author Where it stood:
What this PR does (the single move that subsumes all three):
Result: finding 1 cannot be constructed (budgeted splits are reconciled, not two free lists), finding 3 is explicit (the engine denies on an access decision, not the absence of a budget row), and finding 2 is fail-closed by construction. The sidecar's Keep BUDGET and ACCESS as separate axes. Budget is a quota (how many evals); access is visibility (what an allowed eval returns: nothing / aggregate-only / full per-sample). They are orthogonal. The actual bug behind finding 3 was that access free-rode on budget via ledger membership; giving access its own gate fixes it without merging the axes. Trust ownership. The declared tier belongs with the dataset and scorer (sidecar-owned, baked sidecar-only by the compiler), not on the agent-adjacent Flexibility (same dataset, different visibility across runs). Keep the dataset tier as a default and let the sidecar-side Deferred follow-up (not needed to close the findings): carry per-split access on One behavior change to flag: flipping the unlisted default from |
Stacks on #3 (
harbor-1-core). Addresses the core trust-boundary findings from the review of that PR. 1 of 4 fix PRs (one per stacked Varun PR).What this fixes
no_accessgating was implicit — a split was blocked only because it was absent from the budget ledger, soreserve()raised. The engine never inspectedSplitAccess.resolve_split_access()incore/dataset/base.py(fail-closed: an unlisted split resolves tono_access; novero.harborimport).EvaluationEngine.evaluate()now resolves the tier and hard-rejectsno_accessfor non-admin callers before the ledger. Gated onsplit_accessesbeing configured, so direct-construction callers are unchanged.Policybudgets andsplit_accesseswere two independent author lists, never reconciled — a budgeted split could beviewable(leak) or unlisted.non_viewablewhen unlisted (evaluable, aggregate-only — no label leak, and thetrain_budgetconvenience path keeps working), then reject any budgeted split explicitly tieredviewableorno_access.BudgetLedger.reserve()held theasyncio.Lockacross a synchronouswrite_text()+replace(), stalling the event loop (Greptile P1).asyncio.to_thread()so it no longer blocks the loop, kept under the lock so concurrent flushes can't race the shared temp file and decrement/flush ordering is preserved (a crash never persists more remaining budget than was committed).On the architectural question ("should split access live on the dataset with a real-time check?")
SplitAccess/SplitAccessLevelalready live incore/dataset/base.py; this PR adds the fail-closed resolver next to them and a real-time check inevaluate()— i.e. the engine-side embodiment of that direction. The recommended end-state (per-split tier carried onDatasetInfo,Policy.split_accessesbecoming an override-only field) is a clean follow-up that doesn't change the dataset store schema and isn't needed to close these findings. Full write-up to follow in the design doc / a PR comment.Tests
test_dataset_access.py(new): resolver fail-closed semantics.test_engine.py:no_accessgate fires before the ledger; admin bypasses; unlisted fails closed;non_viewablestill evaluable.test_policy_budget_splits.py(new): auto-tier + reject viewable/no_access;train_budgetdefault path passes.test_budget.py: reserve flushes durably and off the loop (to_thread), and concurrent reserves don't overspend or race the temp file.656 passedon the full suite. The 6 failures / 8 errors present are pre-existing environment issues (litellm auth, subprocess/uv-build integration tests, a path assertion) — confirmed identical on the cleanharbor-1-corebaseline.🤖 Generated with Claude Code
Greptile Summary
This PR tightens split access handling and moves durable budget flushes off the event loop. The main changes are:
no_accessrejection inEvaluationEnginefor non-admin evaluations.split_accessesduring policy initialization.reserve()throughasyncio.to_thread().Confidence Score: 5/5
The change appears safe to merge based on the described implementation and test coverage.
The access-control and budget-ledger changes are covered by focused tests across resolver behavior, engine enforcement, policy reconciliation, durability, and concurrent reserve behavior.
What T-Rex did
Reviews (2): Last reviewed commit: "fix(harbor): fail-closed split access, b..." | Re-trigger Greptile