Compare commits
10
Commits
67df41ae00
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be08edcd7e | ||
|
|
7a2aff7bc1 | ||
|
|
0808e8259a | ||
|
|
950c93c5d4 | ||
|
|
21a5831479 | ||
|
|
4a85220aea | ||
|
|
dd1e6e2645 | ||
|
|
14ff41faf5 | ||
|
|
c3ecdf0972 | ||
|
|
1a34363059 |
@@ -315,6 +315,61 @@ async def create_rule(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new rule in a rulebook (a SHARED rule — keep it general).
|
"""Create a new rule in a rulebook (a SHARED rule — keep it general).
|
||||||
|
|
||||||
|
PROPOSE RULES READILY, AND WRITE ONE WHEN THE OPERATOR SAYS YES. Noticing
|
||||||
|
that something has hardened into a standing instruction is valuable work,
|
||||||
|
and a session that notices it and says nothing has thrown the observation
|
||||||
|
away. So raise it whenever you see one. The single step that belongs
|
||||||
|
between noticing and writing is the operator's yes: a rule binds every
|
||||||
|
future session, and they are the person it binds.
|
||||||
|
|
||||||
|
Their yes is also the only moment the rule is reliably IN FRONT of them.
|
||||||
|
After the write it may not be again for months — a conditional rule is not
|
||||||
|
read aloud at session start, and a project-scoped one does not appear in
|
||||||
|
an unfiltered list_rules() at all. So the proposal is the review.
|
||||||
|
|
||||||
|
When the operator asks for a rule in so many words, that IS the yes —
|
||||||
|
write it and move on. The loop below is for the rule you thought of.
|
||||||
|
|
||||||
|
A PROPOSAL CARRIES FOUR THINGS, and the fourth is the one that decides it:
|
||||||
|
|
||||||
|
1. WHAT it would require — the statement, in the words it would carry,
|
||||||
|
not a gloss of them. The operator is agreeing to text.
|
||||||
|
2. INTENT — what it changes about how work gets done, and what goes
|
||||||
|
wrong today without it. "Be careful about X" is not an intent; the
|
||||||
|
behaviour that would differ tomorrow is.
|
||||||
|
3. WHY NOW — the incident, observation or decision behind it. Pass that
|
||||||
|
record as arose_from_id, and say it in the conversation too: the
|
||||||
|
field is for the reader six months out, the sentence is for the
|
||||||
|
person deciding.
|
||||||
|
4. HOW IT WOULD BE ENFORCED — a test, a CI check, a hook, a schema
|
||||||
|
constraint, a duplicate gate, a review step... or nothing, in which
|
||||||
|
case say so plainly: "nothing — this is prose a session has to
|
||||||
|
remember." Answer this one honestly and it will sometimes dissolve
|
||||||
|
the rule, which is the point rather than a side effect. What a test
|
||||||
|
can assert should BE that test; a rule is what remains when nothing
|
||||||
|
mechanical can hold the thing. A rulebook grows by default and
|
||||||
|
shrinks only on purpose, so a question that prevents a rule is worth
|
||||||
|
more than any question that improves one's wording.
|
||||||
|
|
||||||
|
THEN CLOSE WITH A QUESTION THEY CAN ANSWER IN ONE WORD. Offer three
|
||||||
|
answers, and make the middle one the easy one:
|
||||||
|
|
||||||
|
* "Approve it AS WRITTEN" — you create it with the statement exactly as
|
||||||
|
shown. This is what makes element 1 load-bearing: they approved TEXT,
|
||||||
|
so that text is what gets stored, verbatim.
|
||||||
|
* "LET'S TALK ABOUT IT" — the wording, the scope, the tier, whether it
|
||||||
|
wants to be a rule at all. Most good rules arrive this way, so treat
|
||||||
|
this answer as the expected one rather than a setback.
|
||||||
|
* "NO" — let it go. If the observation is still worth keeping, it is a
|
||||||
|
note (create_note): recorded, findable, and binding on nobody.
|
||||||
|
|
||||||
|
Where the interface offers structured choices, ask it that way — a
|
||||||
|
question with named options is answered in a click, while the same
|
||||||
|
question inside a paragraph is answered by scrolling past. Where it does
|
||||||
|
not, write the three options out as three options. Either way ask once
|
||||||
|
and let the answer stand; re-raising a declined proposal argues a rule
|
||||||
|
into existence, which is the thing this whole loop exists to prevent.
|
||||||
|
|
||||||
A rulebook rule is shared by every project that gets the rulebook: an
|
A rulebook rule is shared by every project that gets the rulebook: an
|
||||||
always_on rulebook binds ALL your projects; a subscribed rulebook binds the
|
always_on rulebook binds ALL your projects; a subscribed rulebook binds the
|
||||||
projects that opt in. So a rulebook rule must read as a general standard —
|
projects that opt in. So a rulebook rule must read as a general standard —
|
||||||
@@ -433,6 +488,16 @@ async def create_project_rule(
|
|||||||
the rule is returned in get_project's applicable_rules (under
|
the rule is returned in get_project's applicable_rules (under
|
||||||
project_rules) and in list_rules(project_id=...).
|
project_rules) and in list_rules(project_id=...).
|
||||||
|
|
||||||
|
PROPOSE, THEN WRITE ON A YES — create_rule's opening carries the whole
|
||||||
|
loop: the four things a proposal states (what it would require, its
|
||||||
|
intent, why now, and how it would be enforced) and the one-word question
|
||||||
|
that closes it (approve as written / talk about it / no). All of it
|
||||||
|
applies here unchanged. Reach for that loop MORE readily on this surface,
|
||||||
|
not less: a project rule stays out of an unfiltered list_rules(), and a
|
||||||
|
conditional one stays out of session start too, so the operator's yes is
|
||||||
|
the one moment this rule is certain to have been seen by the person it
|
||||||
|
binds.
|
||||||
|
|
||||||
Check first whether a rule is the right shape at all — create_rule's
|
Check first whether a rule is the right shape at all — create_rule's
|
||||||
opening asks that question and it applies identically here. A visual
|
opening asks that question and it applies identically here. A visual
|
||||||
standard is a design system; a procedure is a process (create_process);
|
standard is a design system; a procedure is a process (create_process);
|
||||||
|
|||||||
@@ -237,10 +237,37 @@ It is an UPPER BOUND per surface: a pull records the door it came
|
|||||||
those same rules over time: a resident set surfaced thousands of times and
|
those same rules over time: a resident set surfaced thousands of times and
|
||||||
opened never is the dead-weight signal, one tier up.
|
opened never is the dead-weight signal, one tier up.
|
||||||
|
|
||||||
Read it against `sources["write_path_rule"]`. That surface has never once
|
Read it against `sources["write_path_rule"]`. That arm was once believed
|
||||||
declined to fire, and until this block existed there was no way to tell a
|
never to decline — the reading that scoped #3311 — but it was the arm's
|
||||||
well-tuned arm from a bar it cannot fail to clear (#3311). `pull_through`
|
`retrieval_logs` row being written only on calls that FOUND something, so
|
||||||
is the number that tells them apart.
|
the zeros were missing rather than absent (#3497). Measured since, it
|
||||||
|
declines the large majority of its calls like any other surface.
|
||||||
|
|
||||||
|
EVERY COUNTER BLOCK CARRIES ITS OWN COVERAGE — `complete_from` and
|
||||||
|
`covers_window`. `complete_from` is when the number became trustworthy:
|
||||||
|
for one source, its first recorded row; for a section that sums several,
|
||||||
|
the LATEST of theirs, because a total is complete only once every
|
||||||
|
contributor was being written. `covers_window: false` means the window
|
||||||
|
reaches back further than the recording does, so the count is a fraction
|
||||||
|
of the period it appears to describe.
|
||||||
|
|
||||||
|
READ IT BEFORE COMPARING TWO NUMBERS, and especially before comparing
|
||||||
|
across a deploy. A counter added last week, read over a 30-day window,
|
||||||
|
reports a real count against an imagined denominator — and the result is
|
||||||
|
a plausible fraction rather than an obvious zero, which is what makes it
|
||||||
|
dangerous. That reading cost milestone #379 five steps aimed at a defect
|
||||||
|
that did not exist.
|
||||||
|
|
||||||
|
`covers_window` is null, never false, when nothing was ever recorded:
|
||||||
|
"no measurement" is not "partial measurement", the same distinction
|
||||||
|
`suppression`'s null carries a few paragraphs up.
|
||||||
|
|
||||||
|
A SOURCE SHOWING `calls: 0` WAS RECORDING AND MADE NO CALLS. `sources`
|
||||||
|
lists every source the table has ever held, not only those active in the
|
||||||
|
window, so a surface that stopped firing stays visible rather than
|
||||||
|
disappearing — being absent is reserved for a source that has never
|
||||||
|
recorded at all. Its score fields are null, not zero: the calls are a
|
||||||
|
real observation, the distribution is not one.
|
||||||
|
|
||||||
`rule_usage_failed: true` means that read failed while the rest of the
|
`rule_usage_failed: true` means that read failed while the rest of the
|
||||||
readout stood. The counts are still present so a caller can render, but
|
readout stood. The counts are still present so a caller can render, but
|
||||||
|
|||||||
@@ -774,10 +774,17 @@ async def semantic_search_rules(
|
|||||||
is the surfacing question, and it has its own machinery
|
is the surfacing question, and it has its own machinery
|
||||||
(get_applicable_rules) rather than a second, subtly different copy here.
|
(get_applicable_rules) rather than a second, subtly different copy here.
|
||||||
|
|
||||||
`tier` narrows to one tier. The write-path hint passes "conditional",
|
`tier` narrows to one tier, and NONE is the ordinary case. The write-path
|
||||||
because an always-on rule is ALREADY in the session — surfacing it again as
|
and pre-tool hints deliberately pass nothing: an always-on rule is already
|
||||||
a suggestion is pure noise, and noise on a hint that fires on every write
|
in the session, but being in a list from turn zero is not the same as being
|
||||||
is how a hint gets ignored.
|
in front of the reader when the action it governs is taken, and filtering
|
||||||
|
on tier made a whole class of rules permanently ineligible for the one
|
||||||
|
mechanism that surfaces a rule AT the moment. Relevance is the threshold's
|
||||||
|
job; see the block above RULEHINT_LIMIT in services/plugin_context.py for
|
||||||
|
the argument and for what the resulting scores are being read against.
|
||||||
|
|
||||||
|
Pass a tier when a caller genuinely wants one class — a listing, an audit,
|
||||||
|
a UI that renders the tiers apart. Not to approximate relevance.
|
||||||
|
|
||||||
Collapses to best-chunk-per-rule like the note search, so a long rule split
|
Collapses to best-chunk-per-rule like the note search, so a long rule split
|
||||||
across chunks competes once rather than crowding the results with itself.
|
across chunks competes once rather than crowding the results with itself.
|
||||||
|
|||||||
@@ -94,13 +94,17 @@ WRITEPATH_DEFAULT_THRESHOLD = 0.68
|
|||||||
# THE STRUCTURAL ARGUMENT, which is the only kind admissible here (rule 115).
|
# THE STRUCTURAL ARGUMENT, which is the only kind admissible here (rule 115).
|
||||||
# Two facts hold on any install, including one with six rules and no telemetry:
|
# Two facts hold on any install, including one with six rules and no telemetry:
|
||||||
#
|
#
|
||||||
# 1. The eligible corpus is TINY. The arm searches `tier="conditional"`
|
# 1. The eligible corpus is SMALL — every rule an install owns, still only
|
||||||
# rules only — a handful to a few dozen documents against thousands of
|
# a few dozen documents against thousands of notes. A top-k over a small
|
||||||
# notes. A top-k over forty candidates always returns something, so
|
# pool always returns something, so "the best match cleared the bar"
|
||||||
# "the best match cleared the bar" stops meaning "a good match exists"
|
# drifts from "a good match exists" toward "N things were ranked". A bar
|
||||||
# and starts meaning "forty things were ranked". A bar calibrated for
|
# calibrated for best-of-thousands is cleared by best-of-forty as
|
||||||
# best-of-thousands is cleared by best-of-forty as arithmetic, not
|
# arithmetic rather than relevance.
|
||||||
# relevance.
|
# This argument WEAKENED when the arms stopped filtering to one tier
|
||||||
|
# (see the note on that below): a larger pool makes clearing the bar
|
||||||
|
# mean more, not less. The threshold was deliberately left where it was
|
||||||
|
# anyway — moving two variables at once would make the resulting
|
||||||
|
# distribution unreadable, and this one errs toward silence on purpose.
|
||||||
# 2. Rules are short imperative technical English — a far more HOMOGENEOUS
|
# 2. Rules are short imperative technical English — a far more HOMOGENEOUS
|
||||||
# corpus than note prose. #2223 measured the floor for code against prose
|
# corpus than note prose. #2223 measured the floor for code against prose
|
||||||
# at 0.55-0.63 and set 0.68 above it. A more homogeneous corpus has a
|
# at 0.55-0.63 and set 0.68 above it. A more homogeneous corpus has a
|
||||||
@@ -128,9 +132,14 @@ RULEHINT_DEFAULT_THRESHOLD = 0.72
|
|||||||
# ONE rule per write, not two — and this is deliberately NOT a knob.
|
# ONE rule per write, not two — and this is deliberately NOT a knob.
|
||||||
#
|
#
|
||||||
# With a corpus this small, top-k does as much damage as the threshold: k=2
|
# With a corpus this small, top-k does as much damage as the threshold: k=2
|
||||||
# over forty candidates means the second line is almost always the second-best
|
# over a few dozen candidates means the second line is almost always the
|
||||||
# noise, arriving with the same confident framing as the first. Halving k
|
# second-best noise, arriving with the same confident framing as the first.
|
||||||
# halves that regardless of where the bar sits.
|
# Halving k halves that regardless of where the bar sits.
|
||||||
|
#
|
||||||
|
# It also BOUNDS the blast radius of widening the pool (below): with k=1 a
|
||||||
|
# wider corpus can change WHICH rule surfaces and how often one does, but it
|
||||||
|
# can never make a single hint longer. The loudness of one hint and the
|
||||||
|
# eligibility of a rule are separate controls, and only one of them moved.
|
||||||
#
|
#
|
||||||
# It stays a constant because it is a decision about how LOUD one hint may be,
|
# It stays a constant because it is a decision about how LOUD one hint may be,
|
||||||
# not a per-install tuning question. The hint already carries prior art, shape
|
# not a per-install tuning question. The hint already carries prior art, shape
|
||||||
@@ -140,6 +149,45 @@ RULEHINT_DEFAULT_THRESHOLD = 0.72
|
|||||||
# adds a way to misconfigure the surface (rule 25 cuts both ways).
|
# adds a way to misconfigure the surface (rule 25 cuts both ways).
|
||||||
RULEHINT_LIMIT = 1
|
RULEHINT_LIMIT = 1
|
||||||
|
|
||||||
|
# WHY THE ARMS NO LONGER FILTER TO ONE TIER (#3702).
|
||||||
|
#
|
||||||
|
# Both arms used to pass `tier="conditional"`, on the reasoning that an
|
||||||
|
# always-on rule is already in the session, so surfacing it again is pure
|
||||||
|
# noise. That reasoning conflates two different things:
|
||||||
|
#
|
||||||
|
# PRESENT IN CONTEXT — the rule was delivered at session start.
|
||||||
|
# SALIENT AT THE MOMENT — the rule is in front of the reader when the
|
||||||
|
# action it governs is about to be taken.
|
||||||
|
#
|
||||||
|
# A rule handed over in a list at turn zero is present while a session writes
|
||||||
|
# a config value three hundred turns later. It is not surfaced. So the filter
|
||||||
|
# did not merely skip a redundant hint — it made a whole class of rules
|
||||||
|
# permanently ineligible for the only mechanism that puts a rule in front of
|
||||||
|
# an agent AT the moment, and the more important a rule is, the more likely
|
||||||
|
# it was in that class.
|
||||||
|
#
|
||||||
|
# The deeper defect is that the filter was doing the THRESHOLD's job. Whether
|
||||||
|
# a rule belongs in this hint is a relevance question, and a similarity bar is
|
||||||
|
# the control for relevance. A categorical exclusion standing in for a
|
||||||
|
# relevance judgment cannot be tuned, cannot be measured, and cannot be wrong
|
||||||
|
# in a way anybody notices.
|
||||||
|
#
|
||||||
|
# THIS IS A MEASURED CHANGE, NOT A SETTLED ONE. The old comment's fear is
|
||||||
|
# real — a hint that fires on every write and says obvious things teaches the
|
||||||
|
# reader to skip the block, and the surface is then lost along with its true
|
||||||
|
# positives. That fear had simply never been checked. `retrieval_logs` already
|
||||||
|
# records top_score, result_count and the query for every call, so the
|
||||||
|
# evidence now arrives on its own:
|
||||||
|
#
|
||||||
|
# - rules clear the bar often and at high scores -> the fear was justified,
|
||||||
|
# the filter was a crude proxy for a bar set too low, and the WORK IS THE
|
||||||
|
# BAR. Any reinstated filter should then carry a measured reason.
|
||||||
|
# - rules clear rarely, in a thin band near the bar -> the filter was never
|
||||||
|
# the right instrument and relevance was always sufficient.
|
||||||
|
#
|
||||||
|
# Only the eligibility moved. The bar and k=1 were both left exactly where
|
||||||
|
# they were, so the resulting distribution has one cause.
|
||||||
|
|
||||||
# How much of a command reaches the embedding (#3476). A shell call is not a
|
# How much of a command reaches the embedding (#3476). A shell call is not a
|
||||||
# file: most are short, and the ones that are not are usually a heredoc or a
|
# file: most are short, and the ones that are not are usually a heredoc or a
|
||||||
# pasted script whose bulk says nothing about which rule applies. The VERB AND
|
# pasted script whose bulk says nothing about which rule applies. The VERB AND
|
||||||
@@ -1205,7 +1253,7 @@ async def build_write_path_hint(
|
|||||||
rule_t0 = time.perf_counter()
|
rule_t0 = time.perf_counter()
|
||||||
hits = await semantic_search_rules(
|
hits = await semantic_search_rules(
|
||||||
user_id, code or path, limit=RULEHINT_LIMIT,
|
user_id, code or path, limit=RULEHINT_LIMIT,
|
||||||
threshold=cfg["rule_threshold"], tier="conditional",
|
threshold=cfg["rule_threshold"],
|
||||||
)
|
)
|
||||||
rule_ms = (time.perf_counter() - rule_t0) * 1000.0
|
rule_ms = (time.perf_counter() - rule_t0) * 1000.0
|
||||||
fresh = [(score, rule) for score, rule in hits if rule.id not in already]
|
fresh = [(score, rule) for score, rule in hits if rule.id not in already]
|
||||||
@@ -1337,7 +1385,7 @@ async def build_tool_rule_hint(
|
|||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
hits = await semantic_search_rules(
|
hits = await semantic_search_rules(
|
||||||
user_id, query, limit=RULEHINT_LIMIT,
|
user_id, query, limit=RULEHINT_LIMIT,
|
||||||
threshold=cfg["rule_threshold"], tier="conditional",
|
threshold=cfg["rule_threshold"],
|
||||||
)
|
)
|
||||||
duration_ms = (time.perf_counter() - t0) * 1000.0
|
duration_ms = (time.perf_counter() - t0) * 1000.0
|
||||||
|
|
||||||
|
|||||||
@@ -213,10 +213,70 @@ def _bucket(rows: list) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# The aggregate row Postgres would have returned for a source with no rows in
|
||||||
|
# the window: nothing counted, nothing scored. Positional, matching the SELECT
|
||||||
|
# `_bucket` unpacks — calls, zero, cleared, p10, p50, p90, min, max, avg_n,
|
||||||
|
# dur, measured, supp_calls, supp_zero. The three counts are 0 because zero
|
||||||
|
# calls is a real observation; everything else is None because a distribution
|
||||||
|
# nobody sampled has no value, and rendering it as 0.0 would state one.
|
||||||
|
_NO_ROWS_IN_WINDOW = [0, 0, 0, None, None, None, None, None, None, None, 0, 0, 0]
|
||||||
|
|
||||||
|
|
||||||
def _round(v, places: int = 4):
|
def _round(v, places: int = 4):
|
||||||
return None if v is None else round(float(v), places)
|
return None if v is None else round(float(v), places)
|
||||||
|
|
||||||
|
|
||||||
|
async def _complete_from(session, model, user_id) -> dict[str, Any]:
|
||||||
|
"""When each source in `model` started being recorded, and the instant the
|
||||||
|
WHOLE table is complete from. Returns {source: earliest_row, "*": latest}.
|
||||||
|
|
||||||
|
THE GRAIN IS THE SOURCE, and that is the whole point. `retrieval_logs` has
|
||||||
|
rows going back months, so a table-level "earliest row" says months and
|
||||||
|
tells a reader their window is fully covered — while a source added last
|
||||||
|
week has a week of rows and a counter that silently means something else.
|
||||||
|
Per-source is the only grain at which partial coverage is visible.
|
||||||
|
|
||||||
|
THE AGGREGATE USES THE LATEST, NOT THE EARLIEST. A number that sums several
|
||||||
|
sources is complete only once EVERY contributor was recording, so "*" is a
|
||||||
|
max over the sources, not a min. Taking the min here would reproduce the
|
||||||
|
exact reading this exists to prevent: the oldest source vouching for the
|
||||||
|
youngest.
|
||||||
|
|
||||||
|
All-time, deliberately unfiltered by the window — a query bounded by
|
||||||
|
`since` can only ever report something at or after `since`, which answers
|
||||||
|
nothing.
|
||||||
|
"""
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(model.source, func.min(model.created_at))
|
||||||
|
.where(model.user_id == user_id)
|
||||||
|
.group_by(model.source)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
out: dict[str, Any] = {src: ts for src, ts in rows if ts is not None}
|
||||||
|
stamps = list(out.values())
|
||||||
|
out["*"] = max(stamps) if stamps else None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _coverage(complete_from, since) -> dict:
|
||||||
|
"""The two keys every counter block carries, from one timestamp.
|
||||||
|
|
||||||
|
`covers_window` is None — never False — when nothing was ever recorded.
|
||||||
|
"No rows at all" is not "partial coverage", it is no measurement, and the
|
||||||
|
null convention #3497 established for `suppression` holds here for the
|
||||||
|
same reason: absent must not read as a verdict.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
# iso() already returns None for an unset value (#2845) — the guard
|
||||||
|
# belongs on covers_window, which is a verdict, not a serialisation.
|
||||||
|
"complete_from": iso(complete_from),
|
||||||
|
"covers_window": (
|
||||||
|
None if complete_from is None else complete_from <= since
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
||||||
"""What the retrieval telemetry says, per surface, over a window.
|
"""What the retrieval telemetry says, per surface, over a window.
|
||||||
|
|
||||||
@@ -283,6 +343,9 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
|||||||
by_source_rows = None
|
by_source_rows = None
|
||||||
rule_rows = None
|
rule_rows = None
|
||||||
distinct_rules_surfaced = distinct_rules_pulled = 0
|
distinct_rules_surfaced = distinct_rules_pulled = 0
|
||||||
|
# None means the coverage read did not happen — distinct from a table with
|
||||||
|
# no rows, which is {"*": None}. Same reason `read_failed` exists.
|
||||||
|
note_complete = rule_complete = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
@@ -311,8 +374,34 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
|||||||
.group_by(RetrievalLog.source)
|
.group_by(RetrievalLog.source)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
log_complete = await _complete_from(session, RetrievalLog, user_id)
|
||||||
for row in rows:
|
for row in rows:
|
||||||
out["sources"][row[0]] = _bucket(list(row[1:]))
|
source = row[0]
|
||||||
|
bucket = _bucket(list(row[1:]))
|
||||||
|
# Per SOURCE, not per table: retrieval_logs goes back months
|
||||||
|
# while any individual arm may be days old, and the table's
|
||||||
|
# age would vouch for an arm that has barely started.
|
||||||
|
bucket.update(_coverage(log_complete.get(source), since))
|
||||||
|
out["sources"][source] = bucket
|
||||||
|
|
||||||
|
# A source with rows in the table but NONE in this window would
|
||||||
|
# otherwise be absent from the readout — and absent is exactly how
|
||||||
|
# a source that never existed renders, so a surface that WAS
|
||||||
|
# recording and went silent is unreadable (#3720). That is #2663
|
||||||
|
# one level up: the failure that looks like the correct answer.
|
||||||
|
#
|
||||||
|
# Zero here is a real measurement, not a manufactured one. The
|
||||||
|
# all-time query proves the source was recording, and it made no
|
||||||
|
# calls across a window it fully covers — which is why no
|
||||||
|
# `covers_window` special case is needed: a source whose first row
|
||||||
|
# fell after `since` would have that row IN the window and already
|
||||||
|
# hold a bucket, so anything reaching here began before it.
|
||||||
|
for src, first_row in log_complete.items():
|
||||||
|
if src == "*" or first_row is None or src in out["sources"]:
|
||||||
|
continue
|
||||||
|
quiet = _bucket(list(_NO_ROWS_IN_WINDOW))
|
||||||
|
quiet.update(_coverage(first_row, since))
|
||||||
|
out["sources"][src] = quiet
|
||||||
|
|
||||||
# The corpus side, at its own grain. `ambient` mirrors
|
# The corpus side, at its own grain. `ambient` mirrors
|
||||||
# note_usage.usage_for_notes: an ambient surfacing was not a scored
|
# note_usage.usage_for_notes: an ambient surfacing was not a scored
|
||||||
@@ -339,6 +428,7 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
|||||||
.group_by(NoteUsageEvent.event, NoteUsageEvent.source)
|
.group_by(NoteUsageEvent.event, NoteUsageEvent.source)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
note_complete = await _complete_from(session, NoteUsageEvent, user_id)
|
||||||
|
|
||||||
# Distinct-note counts need their OWN queries, and this is not
|
# Distinct-note counts need their OWN queries, and this is not
|
||||||
# fussiness: count(distinct note_id) per (event, source) group
|
# fussiness: count(distinct note_id) per (event, source) group
|
||||||
@@ -466,6 +556,9 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
|||||||
.group_by(RuleUsageEvent.event, RuleUsageEvent.source)
|
.group_by(RuleUsageEvent.event, RuleUsageEvent.source)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
rule_complete = await _complete_from(
|
||||||
|
session, RuleUsageEvent, user_id,
|
||||||
|
)
|
||||||
# The rows carry `source`, so the ranked/ambient split is done
|
# The rows carry `source`, so the ranked/ambient split is done
|
||||||
# below rather than in SQL — the bulk surfaces started emitting
|
# below rather than in SQL — the bulk surfaces started emitting
|
||||||
# on 2026-09-03 (#3473), so there IS an ambient class now.
|
# on 2026-09-03 (#3473), so there IS an ambient class now.
|
||||||
@@ -575,6 +668,10 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
|||||||
}
|
}
|
||||||
usage["by_source"] = by_source
|
usage["by_source"] = by_source
|
||||||
|
|
||||||
|
# The SECTION's coverage, from the latest source to start recording — a
|
||||||
|
# figure that sums several sources is complete only once every one of them
|
||||||
|
# was being written. `_complete_from` computes that as "*".
|
||||||
|
usage.update(_coverage((note_complete or {}).get("*"), since))
|
||||||
out["usage"] = usage
|
out["usage"] = usage
|
||||||
|
|
||||||
# ── Rules, deliberately a SEPARATE block ────────────────────────────
|
# ── Rules, deliberately a SEPARATE block ────────────────────────────
|
||||||
@@ -652,6 +749,7 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
|||||||
round(rule_usage["pulled_by_agent"] / rule_usage["surfaced"], 4)
|
round(rule_usage["pulled_by_agent"] / rule_usage["surfaced"], 4)
|
||||||
if rule_usage["surfaced"] else None
|
if rule_usage["surfaced"] else None
|
||||||
)
|
)
|
||||||
|
rule_usage.update(_coverage((rule_complete or {}).get("*"), since))
|
||||||
out["rule_usage"] = rule_usage
|
out["rule_usage"] = rule_usage
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Both rule-creation tools run the propose-then-approve loop (#3557).
|
||||||
|
|
||||||
|
WHY THIS EXISTS
|
||||||
|
|
||||||
|
Every other gate on `create_rule` and `create_project_rule` is about SHAPE:
|
||||||
|
is this a rule or a process, is it one thing you could violate, is it general
|
||||||
|
enough for a rulebook, is it a near-duplicate. All of those improve a rule
|
||||||
|
someone has already decided to write. None of them asks the prior question —
|
||||||
|
whether the person the rule will bind has agreed to be bound by it.
|
||||||
|
|
||||||
|
That question belongs at the tool, because the tool is the last surface a
|
||||||
|
caller reads before the write, and because the write is less reversible than
|
||||||
|
it looks. The operator's yes is not merely consent; it is the one moment the
|
||||||
|
rule is certainly IN FRONT of them. Afterwards it may not be again for
|
||||||
|
months: a conditional rule is not read aloud at session start, and a
|
||||||
|
project-scoped rule does not appear in an unfiltered `list_rules()` at all.
|
||||||
|
The proposal IS the review, so there had better be one.
|
||||||
|
|
||||||
|
WHY IT IS PHRASED AS A PRACTICE AND NOT A PROHIBITION
|
||||||
|
|
||||||
|
The first cut of this guidance opened "NOT YOURS TO CALL UNPROMPTED." That is
|
||||||
|
the wrong instrument, and the failure it invites is worse than the one it
|
||||||
|
prevents: a caller reading a prohibition stops NOTICING rule-shaped things,
|
||||||
|
rather than noticing them and asking. The wanted behaviour is more proposals,
|
||||||
|
not fewer — spotting that something has hardened into a standing instruction
|
||||||
|
is valuable work, and the only step that was ever missing came after it.
|
||||||
|
|
||||||
|
So the docstrings describe what to DO: propose readily, state four things,
|
||||||
|
close with a question the operator answers in one word. This test is written
|
||||||
|
the same way — it asserts the parts of the loop are present, and has nothing
|
||||||
|
to say about any wording that forbids.
|
||||||
|
|
||||||
|
The fourth element — how the rule would be ENFORCED — is not ceremony. It is
|
||||||
|
the part that sometimes dissolves the rule: a thing a test can assert should
|
||||||
|
be that test, and a rule is what is left when nothing mechanical can hold it.
|
||||||
|
A rulebook grows by default and shrinks only on purpose, so the question that
|
||||||
|
prevents a rule earns more than any question that improves one's wording.
|
||||||
|
|
||||||
|
WHAT THIS PINS, AND WHAT IT DOES NOT
|
||||||
|
|
||||||
|
STRUCTURE, never wording — the same bargain the disambiguator guard (#3123)
|
||||||
|
strikes next door. Each element matches a family of synonyms, so the prose
|
||||||
|
stays free to be rewritten, reordered or sharpened; only DELETING one fails.
|
||||||
|
Pinning phrasing would make every improvement a red build, and a test that
|
||||||
|
punishes editing is a test someone deletes.
|
||||||
|
|
||||||
|
It cannot tell whether an agent actually proposes. Nothing in a docstring
|
||||||
|
can. It catches the regression that really happens: guidance tidied away in
|
||||||
|
a later pass by someone who read it as throat-clearing in front of the Args.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.helpers import tool_doc as _doc
|
||||||
|
|
||||||
|
# Both surfaces, because the one that needs it most is the one that looks
|
||||||
|
# minor. A project rule is the least visible record the system can hold —
|
||||||
|
# absent from an unfiltered list_rules(), and absent from session start too
|
||||||
|
# whenever it is conditional — so the surface that writes one carries the
|
||||||
|
# larger risk while reading as the smaller act.
|
||||||
|
_SURFACES = [
|
||||||
|
("scribe.mcp.tools.rulebooks", "create_rule"),
|
||||||
|
("scribe.mcp.tools.rulebooks", "create_project_rule"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# The loop, element by element, each as a family of ways to say it. A
|
||||||
|
# docstring satisfies an element by containing ANY member — that is the room
|
||||||
|
# left for rewriting. The families deliberately exclude bare words a
|
||||||
|
# docstring would hold by accident ("why", "how", "reason", "rule"), which
|
||||||
|
# would let the assertion pass on prose that says nothing of the kind.
|
||||||
|
_ELEMENTS = {
|
||||||
|
"the invitation to propose": ("propose", "proposal"),
|
||||||
|
"the operator's approval": ("approve", "approval", "says yes", "a yes"),
|
||||||
|
"the rule's intent": ("intent", "what it changes about how work"),
|
||||||
|
"why it is being proposed now": (
|
||||||
|
"why now", "arose_from_id", "the incident", "prompted it",
|
||||||
|
),
|
||||||
|
"how it would be enforced": ("enforc",),
|
||||||
|
"the answers offered back": ("as written", "talk about it", "discuss"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("module", "name"), _SURFACES)
|
||||||
|
@pytest.mark.parametrize("element", sorted(_ELEMENTS))
|
||||||
|
def test_a_rule_surface_carries_every_part_of_the_proposal_loop(
|
||||||
|
module, name, element
|
||||||
|
):
|
||||||
|
"""Each element of propose → state four things → ask survives."""
|
||||||
|
doc = _doc(module, name).lower()
|
||||||
|
assert any(token in doc for token in _ELEMENTS[element]), (
|
||||||
|
f"{name}'s docstring no longer mentions {element}. A caller reads "
|
||||||
|
f"this immediately before writing a rule that will bind every future "
|
||||||
|
f"session, and the proposal is the one moment that rule is certain to "
|
||||||
|
f"be seen by the operator. Say it in whatever words you like; this "
|
||||||
|
f"guard only checks it is still said. See create_rule's opening."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("module", "name"), _SURFACES)
|
||||||
|
def test_the_proposal_loop_comes_before_the_parameter_contract(module, name):
|
||||||
|
"""It has to be read to work, and the Args: block is where reading stops.
|
||||||
|
|
||||||
|
A caller who has decided to make the call skims down to the parameters.
|
||||||
|
Guidance parked below them — or folded into one argument's description —
|
||||||
|
arrives after the decision it was meant to inform, which is the same as
|
||||||
|
not being there.
|
||||||
|
"""
|
||||||
|
doc = _doc(module, name).lower()
|
||||||
|
args_at = doc.find("args:")
|
||||||
|
assert args_at > 0, f"{name}'s docstring has no Args: block"
|
||||||
|
loop_at = min(
|
||||||
|
(doc.find(t) for t in _ELEMENTS["the invitation to propose"]
|
||||||
|
if doc.find(t) >= 0),
|
||||||
|
default=-1,
|
||||||
|
)
|
||||||
|
assert 0 <= loop_at < args_at, (
|
||||||
|
f"{name} introduces the proposal loop at or after its Args: block "
|
||||||
|
f"(loop {loop_at}, args {args_at}). Move it to the opening — a "
|
||||||
|
f"caller who has already decided to write the rule reads the "
|
||||||
|
f"parameters, not the prose under them."
|
||||||
|
)
|
||||||
@@ -160,7 +160,15 @@ async def test_the_arm_searches_on_its_OWN_bar_not_the_code_one():
|
|||||||
kw = search.await_args.kwargs
|
kw = search.await_args.kwargs
|
||||||
assert kw["threshold"] == 0.81, "the arm is still using the code threshold"
|
assert kw["threshold"] == 0.81, "the arm is still using the code threshold"
|
||||||
assert kw["limit"] == pc.RULEHINT_LIMIT
|
assert kw["limit"] == pc.RULEHINT_LIMIT
|
||||||
assert kw["tier"] == "conditional"
|
# NO tier filter (#3702). The arms search every rule the caller owns,
|
||||||
|
# because "already in the session" is not the same as "in front of the
|
||||||
|
# reader at the moment it applies" — and relevance is the threshold's
|
||||||
|
# job, not a category's. If this assertion is failing because a tier
|
||||||
|
# argument came back, read the block above RULEHINT_LIMIT first: the
|
||||||
|
# filter may legitimately return, but only carrying a measured reason.
|
||||||
|
assert "tier" not in kw or kw["tier"] is None, (
|
||||||
|
"the arm is filtering the rule corpus by tier again"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -638,3 +638,212 @@ async def test_ambient_alone_reports_no_ratio(_dispose_engine):
|
|||||||
assert ru["pull_through"] is None
|
assert ru["pull_through"] is None
|
||||||
finally:
|
finally:
|
||||||
await cleanup()
|
await cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Window coverage (#3712) ────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# A counter added last week, read over a 30-day window, reports a real count
|
||||||
|
# against an imagined denominator. The result is a plausible FRACTION rather
|
||||||
|
# than an obvious zero, which is what makes it dangerous — #379 spent five
|
||||||
|
# planned steps on a defect that turned out to be a window opening before the
|
||||||
|
# recording it was measuring existed.
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_says_nothing_rather_than_false_when_nothing_was_recorded():
|
||||||
|
"""Null, never False. "No measurement" is not "partial measurement".
|
||||||
|
|
||||||
|
The same distinction `suppression`'s null carries (#3497): absent must not
|
||||||
|
read as a verdict. A False here would assert the window is under-covered,
|
||||||
|
which is a claim nobody is in a position to make.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from scribe.services.retrieval_telemetry import _coverage
|
||||||
|
|
||||||
|
since = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
||||||
|
assert _coverage(None, since) == {
|
||||||
|
"complete_from": None, "covers_window": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_reads_a_start_before_the_window_as_covered():
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from scribe.services.retrieval_telemetry import _coverage
|
||||||
|
|
||||||
|
since = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
||||||
|
older = datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||||
|
newer = datetime(2026, 9, 5, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
assert _coverage(older, since)["covers_window"] is True
|
||||||
|
assert _coverage(newer, since)["covers_window"] is False, (
|
||||||
|
"a counter that started inside the window covers only part of it"
|
||||||
|
)
|
||||||
|
assert _coverage(newer, since)["complete_from"] == newer.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_coverage_is_per_source_because_the_table_is_older_than_its_arms(
|
||||||
|
_dispose_engine,
|
||||||
|
):
|
||||||
|
"""THE grain question, and the reason a per-table answer is useless.
|
||||||
|
|
||||||
|
`retrieval_logs` accumulates for months. A table-level "earliest row"
|
||||||
|
therefore says months for every source it holds — including one added
|
||||||
|
days ago whose counter means something quite different. The old source
|
||||||
|
would vouch for the young one, which is exactly the reading this exists
|
||||||
|
to prevent.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.retrieval_log import RetrievalLog
|
||||||
|
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||||
|
|
||||||
|
UID = 990077
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
async with async_session() as s:
|
||||||
|
# An old surface, recording since well before any window we ask for,
|
||||||
|
# AND still recording inside it. Both rows are needed: `complete_from`
|
||||||
|
# comes from the all-time query, but a source only gets a bucket at all
|
||||||
|
# if it has rows in the window, so the 90-day row alone would leave
|
||||||
|
# nothing to assert on.
|
||||||
|
s.add(RetrievalLog(
|
||||||
|
user_id=UID, source="auto_inject", result_count=1,
|
||||||
|
created_at=now - timedelta(days=90),
|
||||||
|
))
|
||||||
|
s.add(RetrievalLog(
|
||||||
|
user_id=UID, source="auto_inject", result_count=1,
|
||||||
|
created_at=now - timedelta(days=1),
|
||||||
|
))
|
||||||
|
# A young arm, first written INSIDE the window below.
|
||||||
|
s.add(RetrievalLog(
|
||||||
|
user_id=UID, source="pre_tool_rule", result_count=1,
|
||||||
|
created_at=now - timedelta(days=2),
|
||||||
|
))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
out = await retrieval_summary(UID, days=30)
|
||||||
|
|
||||||
|
assert out["sources"]["auto_inject"]["covers_window"] is True
|
||||||
|
assert out["sources"]["pre_tool_rule"]["covers_window"] is False, (
|
||||||
|
"the young arm was reported as covering a 30-day window — the "
|
||||||
|
"table's age has been allowed to vouch for one of its sources"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
async with async_session() as s:
|
||||||
|
await s.execute(delete(RetrievalLog).where(RetrievalLog.user_id == UID))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_surface_that_went_silent_is_not_the_same_as_one_that_never_ran(
|
||||||
|
_dispose_engine,
|
||||||
|
):
|
||||||
|
"""#3720 — absent is how "never existed" renders, so it cannot also be how
|
||||||
|
"stopped recording" renders.
|
||||||
|
|
||||||
|
A surface losing its recorder is one of the failures this milestone exists
|
||||||
|
to make visible, and dropping it from the readout is the most complete way
|
||||||
|
to hide it. Zero here is a real measurement: the table proves the source
|
||||||
|
was recording, and it made no calls across a window it fully covers.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.retrieval_log import RetrievalLog
|
||||||
|
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||||
|
|
||||||
|
UID = 990078
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
async with async_session() as s:
|
||||||
|
# Recorded once, well before the window, and never since.
|
||||||
|
s.add(RetrievalLog(
|
||||||
|
user_id=UID, source="auto_inject", result_count=3, top_score=0.81,
|
||||||
|
created_at=now - timedelta(days=60),
|
||||||
|
))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
out = await retrieval_summary(UID, days=7)
|
||||||
|
|
||||||
|
assert "auto_inject" in out["sources"], (
|
||||||
|
"a source with rows in the table but none in the window was "
|
||||||
|
"dropped from the readout — a surface that stopped recording now "
|
||||||
|
"reads exactly like one that never existed"
|
||||||
|
)
|
||||||
|
quiet = out["sources"]["auto_inject"]
|
||||||
|
assert quiet["calls"] == 0
|
||||||
|
# The window IS covered; what was observed across it is nothing.
|
||||||
|
assert quiet["covers_window"] is True
|
||||||
|
# ...but nothing was sampled, so no distribution may be claimed. A
|
||||||
|
# zeroed score would assert a measurement, which is #3311's mistake.
|
||||||
|
assert quiet["top_score"] == {
|
||||||
|
"p10": None, "p50": None, "p90": None, "min": None, "max": None,
|
||||||
|
}
|
||||||
|
assert quiet["suppression"] is None
|
||||||
|
assert quiet["avg_result_count"] is None
|
||||||
|
finally:
|
||||||
|
async with async_session() as s:
|
||||||
|
await s.execute(delete(RetrievalLog).where(RetrievalLog.user_id == UID))
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_section_is_complete_only_from_its_latest_contributor(
|
||||||
|
_dispose_engine,
|
||||||
|
):
|
||||||
|
"""A sum is complete once EVERY contributor was being written — so the
|
||||||
|
section takes the LATEST first-row, not the earliest.
|
||||||
|
|
||||||
|
Taking the earliest would be worse than reporting nothing: it would pick
|
||||||
|
the oldest source in the table and use it to certify a total that a
|
||||||
|
newer source is still only partly contributing to. That is the original
|
||||||
|
error in miniature.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.rule_usage import RuleUsageEvent
|
||||||
|
from scribe.services.retrieval_telemetry import retrieval_summary
|
||||||
|
|
||||||
|
UID = 990078
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
old = now - timedelta(days=90)
|
||||||
|
young = now - timedelta(days=2)
|
||||||
|
async with async_session() as s:
|
||||||
|
s.add_all([
|
||||||
|
RuleUsageEvent(
|
||||||
|
user_id=UID, rule_id=1, event="surfaced",
|
||||||
|
source="list_always_on_rules", created_at=old,
|
||||||
|
),
|
||||||
|
RuleUsageEvent(
|
||||||
|
user_id=UID, rule_id=2, event="surfaced",
|
||||||
|
source="pre_tool_rule", created_at=young,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
out = await retrieval_summary(UID, days=30)
|
||||||
|
ru = out["rule_usage"]
|
||||||
|
|
||||||
|
assert ru["complete_from"] == young.isoformat(), (
|
||||||
|
"the section reported completeness from its OLDEST source; a "
|
||||||
|
"total is only as complete as its newest contributor"
|
||||||
|
)
|
||||||
|
assert ru["covers_window"] is False
|
||||||
|
finally:
|
||||||
|
async with async_session() as s:
|
||||||
|
await s.execute(delete(RuleUsageEvent).where(RuleUsageEvent.user_id == UID))
|
||||||
|
await s.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user