Skip to content

fix: revoke_reader owner guard (#215), until-only window anchoring (#214), take_sample slot race - #216

Draft
NikolayS wants to merge 6 commits into
mainfrom
release/fix-independent-blockers
Draft

fix: revoke_reader owner guard (#215), until-only window anchoring (#214), take_sample slot race#216
NikolayS wants to merge 6 commits into
mainfrom
release/fix-independent-blockers

Conversation

@NikolayS

@NikolayS NikolayS commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Fixes #214 and #215, plus the take_sample() slot race and an ACL-convergence gap the first two
uncovered. All four are independent of the rollup_1h grain cluster (#210 / #131 / #150), so this
can land without waiting on that decision.

Found by the pre-tag audit — see #160.

Draft on purpose. Per CLAUDE.md this needs REV and your explicit approval. It has neither.

What's here

Commit Fix
01c54ce #215revoke_reader() bricked a non-superuser-owned install. It now refuses when the target owns schema ash, is current_user, or is a superuser. ash._admin_funcs() moved into the admin set so revoke_reader stops stripping the helper grant_reader() initialises from.
f1d9d7e #214 — passing only until silently answered a window an arbitrary distance away. until now resolves first, since defaults to until minus the reader's span, and since > until raises instead of being rewritten by the #63 clamp.
724a0fa slot racetake_sample() resolved query-map ids against the slot it read at the top but omitted slot from the INSERT, so the column DEFAULT re-evaluated under a fresh snapshot. A rotation landing mid-sample stamped old-dictionary ids with the new slot: NULL attribution, then unrelated query ids once the new slot reused map ids 1,2,3. Passed validation, never touched insert_errors — undetectable after the fact.
037896d Aligns the shipped catalog comments and blueprints/AAS_API.md with the #214 rule.
04233f9 ACL convergence — because _admin_funcs() became admin-only, an EXECUTE grant handed out by 2.0 beta 1 could never be withdrawn (CREATE OR REPLACE preserves ACLs; the corrected revoke_reader skips admin functions). A beta1-upgraded install kept privileges a fresh install does not grant, pg_monitor included. Hardening now revokes from named non-owner grantees, not just PUBLIC.
1b1fac0 Release notes for the above.

Verification

Every fix was proven RED against unpatched SQL first, then GREEN — and independently
re-derived by a second reviewer working from a clean checkout of the same base commit, not from
the author's evidence.

  • Full CI replay: 51/51 on the unpatched baseline, 54/54 on this branch, across PG 14, 18 and 19beta1.
  • A 60-case behavioural battery diffed unpatched-vs-patched: the only differences are the six
    until-only cases and nine inverted-window cases that now raise. Everything else byte-identical —
    all ten zero-arg readers, periods() still anchoring on until, the bug: _at readers raise 'integer out of range' on absurd timestamps — same class as #51 #63 clamp still applying.
  • Fresh install, full 1.0→2.0 chain, double re-apply, and fresh-vs-chain schema equivalence
    (288 catalog rows) all clean.
  • 04233f9 additionally asserts a second re-apply is a fixed point, which is the property
    that actually matters for convergence.

The zero-argument regression guard was explicitly checked for tautology: run alone against
unpatched SQL it passes, while the inverted-window block still fails. That is the PR #131
failure mode being guarded rather than reproduced.

Review notes

🤖 Generated with Claude Code

https://claude.ai/code/session_014HzBGzjFyN8dXZHbdWmYBj

NikolayS and others added 6 commits August 3, 2026 13:26
ash.revoke_reader() ends with REVOKE USAGE ON SCHEMA ash, so pointing it
at the owner of schema ash removed the install's access to its own
schema: take_sample(), status(), every reader and grant_reader() itself
then failed with "permission denied for schema ash", with no supported
way back. A superuser owner is immune through its ACL bypass, which is
why the CI suite — which installs as postgres — never saw it.

Refuse the three target classes that are not monitoring roles: the owner
of schema ash, the calling role, and superusers. grant_reader() stays
asymmetric on purpose; granting to any of them is harmless.

Re-granting schema USAGE by hand still left grant_reader() failing with
"permission denied for function _admin_funcs": ash._admin_funcs() was
missing from the admin exclusion list, so the reader bundle covered it
and revoking that bundle from the owner stripped the owner's own
implicit EXECUTE on the helper grant_reader() initialises from. It is
now admin-only; no reader calls it.

Test: a dedicated database owned by a plain login role, with pg_ash
installed as that role, asserting the refusal, that nothing was revoked,
that grant_reader() still works, that a reader role never receives
ash._admin_funcs(), and that revoking a real monitoring role is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014HzBGzjFyN8dXZHbdWmYBj
since and until were defaulted independently — since to now() - <span>,
until to now() — and since > until was never checked. So
`ash.top('wait_event', until => now() - interval '3 hours')` produced an
inverted window, the #63 degenerate-window guard rewrote it to
[now() - 1h, now() - 1h + 1min), and the reader returned a full,
plausible profile of a window three hours away with no NOTICE.

New ash._resolve_window() resolves both ends in one place, the way
ash.periods() already anchors: until defaults to now(), since defaults to
until - default_span so the span always hangs off the window END, and an
inverted window raises. aas, timeline, top, _samples, report (1 day span),
chart and summary all resolve through it; compare validates both of its
windows in its own frame so the error names ash.compare and not the
delegated ash.aas.

The #63 clamp is unchanged and still runs after resolution, on the
minute-floored int4 bounds, where it does its actual job of keeping
start_ts + 60 from wrapping past INT4_MAX.

Test: two seeded rollup blocks (target at until, decoy at now()) so the
until-only window is asserted by the values it returns, not just by its
bounds; an inverted window is asserted to raise on all eight readers;
and a zero-argument regression block asserts the exact default window and
that no reader rejects its own no-argument call — that block passes
identically on unpatched and patched SQL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014HzBGzjFyN8dXZHbdWmYBj
v_current_slot is read once near the top of ash.take_sample() and drives
both the query_map_N registration INSERT and the query_map_all join that
packs map ids into sample.data — but the sample INSERT omitted the `slot`
column, so it fell back to the table DEFAULT ash.current_slot(), which
re-reads ash.config under the INSERT statement's own snapshot.

ash.rotate() takes a different advisory objid than the sampler, so
nothing serializes the two. A rotation committing between the dictionary
read and the INSERT stored ids packed from the OLD slot's dictionary
stamped with the NEW slot. The row still satisfies _sample_data_is_valid,
so nothing warns and insert_errors is not bumped: the sample decodes to
NULL query_ids, then to unrelated query_ids once the new slot's identity
sequence re-issues 1, 2, 3...

Name slot in the column list so the value read at the top is the value
stored.

Test: a real concurrent rotation cannot be landed deterministically in
the middle of one take_sample() call from a single session, so the window
is injected — ash.current_slot() is replaced, inside a transaction that
is rolled back, with a probe returning the true slot on its first
evaluation and a rotated slot on every later one, which is exactly the
divergence a rotation opens. The stored slot must be the first value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014HzBGzjFyN8dXZHbdWmYBj
The #214 fix changed two user-visible things on eight readers: the window
is now resolved from its END (until first, then since = until - span), and
an inverted window raises. Three shipped contracts still stated the old
rule, so a reader of the catalog, the blueprint, or the release notes was
told something the code no longer does.

ash.report's catalog comment ended "Returns null when the window has no
coverage; never raises", which
"select ash.report(now(), now() - interval '1 hour')" now falsifies. Of the
two ways out — update the comment, or exempt report from the raise — this
takes the comment, because:

  * the promise was always about DATA, not arguments. AAS_API.md §4 states
    it precisely: "Never raises for missing data ... if the whole window
    has no coverage at all, returns null". An inverted window is not
    missing data, it is a malformed request.
  * exempting report would have to answer it with null, which is the same
    value a legitimately uncovered window returns. A machine consumer that
    (correctly) treats null as "no coverage, skip ingestion" would then
    silently skip periods because of its OWN argument bug, with nothing in
    the payload to distinguish the two — reintroducing exactly the
    silent-wrong-answer class #214 removed, in the one reader built for
    automation.
  * report would become the only reader of the eight where a reversed pair
    is not an error, so a caller could not reason uniformly about the API.

The narrow guarantee a consumer actually depends on ("null means no data,
not a failure") is kept and spelled out.

Also: aas/samples/report/chart/summary described their defaults as "last
1 hour"/"last 1 day" unconditionally, true only when until is null;
AAS_API.md §1/§3/§4 and AAS_USER_STORIES.md §6 specified
since -> now() - '1 hour'. All now state the anchored-on-until rule.
AAS_USER_STORIES.md was not in the reported list but carries the identical
claim and cross-references AAS_API.md §1, so it is corrected with them.

RELEASE_NOTES.md had no entry for a change that introduces a new
user-visible error class on eight readers; added one under the file's
existing "## Fixes" section convention (there is no
"## Fixes since 2.0 beta 1" heading on this branch to append to).

No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014HzBGzjFyN8dXZHbdWmYBj
The #215 fix made ash._admin_funcs() admin-only, but 2.0 beta 1 had already handed it to every configured reader. CREATE OR REPLACE preserves that ACL, while the corrected revoke_reader() now skips admin functions, so neither an upgrade nor the supported revoke/re-grant cycle could remove the stale capability. pg_monitor was affected on every beta1 install too.

Extend the admin hardening pass to revoke explicit grants from every named non-owner grantee before re-granting the schema owner. The function owner is never revoked, and a supported secondary admin keeps access through membership in the owner role. Repeating the installer is therefore a fixed point instead of inheriting the prior version's reader bundle.

Exercise the real beta1 tag in CI under a non-superuser owner, with both a named reader and pg_monitor carrying the old grant. Assert the stale grants disappear, the owner and inherited admin remain effective, a second re-apply produces the same ACL snapshot, and the upgraded snapshot matches a fresh install.
Add release-note entries for the revoke_reader owner guard, take_sample slot consistency, and admin-function ACL convergence. Call out the new errors and the installer re-apply consequence for direct admin grants.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: passing only until silently answers a completely different window

1 participant