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
co-authored by Claude Opus 5
parent 20bb569b00
commit 1b8c9f948c
31 changed files with 2226 additions and 377 deletions
+21 -54
View File
@@ -1,63 +1,30 @@
{% from "_macros.html" import icon %}
{#
The share panel on a detail page.
The share panel's placeholder on a detail page.
Sharing grants *reading*. Two people editing one note with no history and no
merge is worse than the inconvenience of copying it, so there is no "can edit"
here and the copy is deliberate rather than missing.
It fetches `_share_panel.html` on load rather than being rendered inline, and
that is the whole change: the panel used to be checkboxes inside the
resource's *save form*, so a share only happened if you also saved the
resource, and the list of candidates was every group and every account on the
instance, unpaginated, on every detail page.
Only the owner sees this at all: someone a thing was shared with cannot share
it onward, which keeps "who can see this" answerable by asking one person.
Sharing still grants *reading*. Two people editing one note with no history and
no merge is worse than the inconvenience of copying it, so there is no "can
edit" and its absence is deliberate rather than missing.
Only the owner sees it at all — someone a thing was shared with cannot share it
onward — and the route enforces that as well, because a template is not a
permission check.
#}
{% if is_owner and can_share %}
<section class="card">
<h2 class="card__title">
Shared with
{% if shared_users or shared_groups %}
<span class="badge badge--leaf">{{ shared_users|length + shared_groups|length }}</span>
{% else %}
<span class="badge">nobody</span>
{% endif %}
</h2>
<p class="card__lede">
They will be able to read this, and their models will find it. They cannot
change it or share it on.
</p>
{% if groups %}
<div class="field">
<label class="field__label">Groups</label>
<div class="checkbox-row">
{% for group in groups %}
<label class="checkbox">
<input type="checkbox" name="share_group" value="{{ group.id }}"
{{ 'checked' if group.id in shared_groups }}>
<span>{{ group.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% if people %}
<div class="field">
<label class="field__label">People</label>
<div class="checkbox-row">
{% for person in people %}
<label class="checkbox">
<input type="checkbox" name="share_user" value="{{ person.id }}"
{{ 'checked' if person.id in shared_users }}>
<span>{{ person.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% if not groups and not people %}
<p class="muted text-sm">There is nobody else on this instance yet.</p>
{% endif %}
</section>
<div hx-get="/api/library/share/{{ share_kind }}/{{ share_id }}"
hx-trigger="load"
hx-target="this"
hx-swap="outerHTML">
<section class="card">
<h2 class="card__title">Shared with <span class="badge">…</span></h2>
</section>
</div>
{% elif not is_owner %}
<div class="alert">
{{ icon("users", "alert__icon") }}
@@ -0,0 +1,95 @@
{% from "_macros.html" import icon %}
{#
Who can see one thing, and the search that changes it.
Swapped into itself after every change, so what is on screen is always what is
stored -- the old panel was a set of checkboxes that only took effect if the
resource happened to be saved afterwards, which is a control that silently
does nothing.
Every fetch in here names its own `hx-target`. This fragment is included on
pages whose forms carry an inherited target, and an element that fetches
without one aims at whatever an ancestor said -- the bug the jobs chip had, and
the reason `tests/test_chat.py` walks the composer for it.
The switches are `<label>`s carrying no `role="menuitem"`, for the reason the
scope menu's are: `ui.js` closes a picker when a menuitem is clicked, which is
right for an action and wrong for a list you set several of.
#}
<section class="card" id="share-panel">
<h2 class="card__title">
Shared with
{% if share_count %}
<span class="badge badge--leaf">{{ share_count }}</span>
{% else %}
<span class="badge">nobody</span>
{% endif %}
</h2>
<p class="card__lede">
They will be able to read this, and their models will find it. They cannot
change it, delete it, or share it on — so “who can see this?” stays a
question you can answer.
</p>
<div class="field">
<label class="field__label" for="share-search">Find somebody</label>
<input class="input" id="share-search" type="search" name="q" value="{{ q }}"
placeholder="Name, email or group…"
hx-get="/api/library/share/{{ kind }}/{{ resource.id }}"
hx-trigger="input changed delay:250ms, search"
hx-target="#share-panel"
hx-swap="outerHTML">
{% if truncated %}
<p class="field__hint">
Showing the first few. Type to narrow it — anything already shared stays
listed whatever you search for.
</p>
{% endif %}
</div>
{% if groups %}
<div class="field">
<label class="field__label">Groups</label>
<div class="checkbox-row">
{% for group in groups %}
{% set on = group.id in shared_groups %}
<label class="checkbox">
<input type="checkbox" {{ 'checked' if on }}
hx-post="/api/library/share/{{ kind }}/{{ resource.id }}"
hx-vals='{"principal_type": "group", "principal_id": "{{ group.id }}",
"on": "{{ 'false' if on else 'true' }}", "q": "{{ q }}"}'
hx-target="#share-panel"
hx-swap="outerHTML">
<span>{{ group.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% if people %}
<div class="field">
<label class="field__label">People</label>
<div class="checkbox-row">
{% for person in people %}
{% set on = person.id in shared_users %}
<label class="checkbox">
<input type="checkbox" {{ 'checked' if on }}
hx-post="/api/library/share/{{ kind }}/{{ resource.id }}"
hx-vals='{"principal_type": "user", "principal_id": "{{ person.id }}",
"on": "{{ 'false' if on else 'true' }}", "q": "{{ q }}"}'
hx-target="#share-panel"
hx-swap="outerHTML">
<span>{{ person.name }} <span class="faint text-xs">{{ person.email }}</span></span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% if not groups and not people %}
<p class="muted text-sm">
{% if q %}Nobody matches “{{ q }}”.{% else %}There is nobody else here yet.{% endif %}
</p>
{% endif %}
</section>
@@ -13,6 +13,13 @@
everything in it comes with it.
</p>
{# Links rather than a form, so a filtered view is a URL you can keep. #}
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
<a class="filter-tab {{ 'is-active' if not shared }}" href="/library/knowledge">All bases</a>
<a class="filter-tab {{ 'is-active' if shared }}"
href="/library/knowledge?shared=1">Shared with me</a>
</div>
{% if error %}
<div class="alert alert--error">{{ icon("warning", "alert__icon") }} <span>{{ error }}</span></div>
{% endif %}
@@ -15,6 +15,15 @@
yours, not its.
</p>
{#
Two views, as links, so a filtered list is a real URL you can keep -- the same
shape the admin lists use. A badge on a row answers "is this mine?"; the question somebody has is
"what have people given me?", which a mixed list of two hundred cannot answer.
#}
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
<a class="filter-tab {{ 'is-active' if not shared }}" href="/library/notes">All notes</a>
<a class="filter-tab {{ 'is-active' if shared }}" href="/library/notes?shared=1">Shared with me</a>
</div>
<form method="get" action="/library/notes" class="btn-row" style="margin-bottom: var(--sp-5)">
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
placeholder="Search notes…">
@@ -16,6 +16,14 @@
change can be read and undone.
</p>
{#
Two views, as links, so a filtered list is a real URL you can keep -- the same
shape the admin lists use. Same as the notes list.
#}
<div class="filter-tabs" style="margin-bottom: var(--sp-4)">
<a class="filter-tab {{ 'is-active' if not shared }}" href="/library/skills">All skills</a>
<a class="filter-tab {{ 'is-active' if shared }}" href="/library/skills?shared=1">Shared with me</a>
</div>
<form method="get" action="/library/skills" class="btn-row" style="margin-bottom: var(--sp-5)">
<input class="input" type="search" name="q" value="{{ q }}" style="flex: 1"
placeholder="Search skills…">