Grants that outlive what they name, and a rule you can read

sharing.forget_principal has existed since shares did, documented as the thing
that stops a recycled id inheriting somebody's grant, and was called by nobody.
Deleting a group left every grant naming it; deleting an account left both the
grants to it and the grants of its own work -- that second half is the one
nothing else could catch, since their rows cascade and the shares of those rows
have nothing to cascade from. Both now run before the delete, while the rows are
still findable, and a deleted resource forgets its own.

library.share defaulted to False, which meant sharing shipped documented as done
and unreachable: the panel only renders for somebody holding it, so out of the
box nobody could share anything and nothing said why. It is on.

The panel itself was checkboxes inside the resource's *save form*, listing every
group and every account on the instance, unpaginated, on every detail page -- and
a tick only took effect if you also saved the resource. It is its own routes now:
search, one grant per POST, the panel re-rendered from what is stored. Anything
already shared stays listed whatever the search says, or removing a grant would
mean searching for the name it was given to.

Reports join the shareable set and memories still do not: a finished piece of
work is the thing somebody most wants to hand over, and a record about a person
is not content to pass round. reports.visible became sharing.visible_to, which is
the one line its own docstring predicted. Two things fell out: `owned` beside
`get`, because sharing grants reading and deleting is the owner's alone; and
reading somebody else's report no longer clears their unread dot.

Permissions gained the answer to "what can this person actually do?" --
explain() is resolve()'s working shown rather than thrown away, naming admin, the
baseline, or the groups that granted each one. That is the simulation the union
rule exists to make unnecessary, and until now the only way to get it was to open
every group and read the grids by eye. Users and groups are list-plus-detail, and
membership is edited from one side: it was on both, and a full-form POST from
either overwrote what the other had shown.

Read and write are split for notes, memory and skills -- checked on the tool's
declared risk, after the gate so it can only narrow, and defaulting on.

Quotas are the union rule applied to numbers, with the corner that makes it
interesting: zero means "no limit" and wins outright, or a group saying unlimited
would count for less than one saying a million. Absent means "no opinion".
_narrower folds a group's ceiling with the instance's and is deliberately not
min, for the same reason. Five axes, enforced where each is knowable -- before a
reply is built, before a second one starts, on an agent reply's clock, before a
minute of GPU, and beside the helper cap -- and usage is recorded even for a
reply that was stopped or errored, because an endpoint charges either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-06 16:48:14 +02:00
parent 20bb569b00
commit 1b8c9f948c
31 changed files with 2226 additions and 377 deletions
+57
View File
@@ -28,6 +28,7 @@ from sqlalchemy import select
from lembas.db.models import KIND_AGENT, ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
from lembas.db.session import session_scope
from lembas.security import permissions
from lembas.services import canvas as canvas_service
from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
@@ -36,6 +37,7 @@ from lembas.services import metrics as metrics_service
from lembas.services import prompts as prompts_service
from lembas.services import push as push_service
from lembas.services import tools as tools_service
from lembas.services import usage as usage_service
from lembas.services.agent import policy as agent_policy
from lembas.services.agent import session as agent_session
from lembas.services.agent import tools as agent_tools
@@ -437,6 +439,21 @@ async def shutdown() -> None:
await task
def _narrower(instance: float, quota: int) -> float:
"""The tighter of two ceilings, where **zero means no limit**.
Not `min`: a zero on either side would win and turn "no opinion" into "no
time at all". Written once and used wherever a group's number meets the
instance's, because getting it wrong in one of those places is a limit that
silently stops working.
"""
if instance <= 0:
return float(quota)
if quota <= 0:
return float(instance)
return float(min(instance, quota))
async def _run(generation: Generation) -> None:
"""Produce one reply, then persist it. Never raises into the task.
@@ -483,6 +500,16 @@ async def _run(generation: Generation) -> None:
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
owner = db.get(User, chat.user_id)
# Before the request is built, not while it streams. Every other
# budget here can only be noticed part way through and so ends with
# `_wrap_up` asking for a final answer; this one is knowable in
# advance, and a reply that trails off because a month ran out mid
# sentence would be the failure `_wrap_up` exists to prevent.
over = usage_service.over_token_budget(db, owner)
if over:
generation.error = over
return
# Read while the session is open: everything below outlives it.
# Resolved once, so that what the loop is allowed to *run* is the
# same set the endpoint was *offered* -- not whatever happens to
@@ -520,6 +547,10 @@ async def _run(generation: Generation) -> None:
# vision model, a plain string to anything else, or the endpoint
# rejects the whole request.
vision = chat_service.model_supports(db, chat, "vision")
# Resolved while the session is open, like everything else here.
# Empty for an admin and for a user in no group, which is every
# instance that has not set one -- see permissions.limits_for.
quota = permissions.limits_for(db, owner)
chat_rounds = settings_store.chat_rounds(db)
# A helper's chat is bounded by its own number, not the instance's.
# Only reached in an *ordinary* helper chat -- an agent one is sized
@@ -532,6 +563,15 @@ async def _run(generation: Generation) -> None:
nudge_enabled = bool(settings_store.agents(db).get("nudge_unfinished"))
limits = tool_context.agent.limits if tool_context.agent else None
# A group's ceiling narrows the instance's, never widens it. `min` of
# two numbers where zero means "no limit" cannot be written as `min`:
# the zero would win and turn a group with no opinion into an unlimited
# one, so the two are folded by `_narrower`.
if limits is not None and quota.get("agent_seconds"):
limits = replace(
limits,
wall_seconds=_narrower(limits.wall_seconds, quota["agent_seconds"]),
)
# A ceiling, not a schedule -- the loop below ends the moment a round
# produces no tool calls, which is the model saying it is done. Zero
# means an ordinary chat has no ceiling either; `steps` is already a
@@ -2083,6 +2123,23 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None:
message.stopped = generation.stopped
message.complete = True
# What this reply cost, against the account's month. Here because
# this is the single writer and it runs for a reply that finished, a
# reply that was stopped and a reply that errored alike -- an
# endpoint charges for tokens it generated whether or not anybody
# wanted them, and a quota that only counted happy paths is one a
# Stop button can walk past. `metrics.from_generation` is the one
# place the three figures are worked out, so this is the same
# arithmetic the bubble shows.
spent = metrics_service.from_generation(generation)
usage_service.record(
db,
chat.user_id,
prompt_tokens=spent.prompt_tokens,
completion_tokens=spent.completion_tokens,
images=len(generation.attachment_ids),
)
if title and not chat.title_generated:
chat.title = title
chat.title_generated = True