Skip to content

A script is personal: its owner and admins, with admin ownership transfer (#1404) - #1409

Merged
cjimti merged 1 commit into
mainfrom
feat/1404-personal-scripts
Aug 21, 2026
Merged

A script is personal: its owner and admins, with admin ownership transfer (#1404)#1409
cjimti merged 1 commit into
mainfrom
feat/1404-personal-scripts

Conversation

@cjimti

@cjimti cjimti commented Aug 21, 2026

Copy link
Copy Markdown
Member

Closes #1404

A script belongs to one person

Its owner sees it, edits it, runs it, and schedules it. Administrators do all four on every script. Nobody else is told it exists.

Script.OwnedBy (pkg/script/script.go) is the one definition, and every surface answers through it rather than keeping its own: the manage_script read and edit paths (internal/platform/scriptlayer/scriptlayer.go), the store's list and search predicates (internal/platform/scriptstore/scriptstore.go, search.go), the portal routes (internal/httpserver/scripthttp/portal.go), search and fetch (pkg/knowledge/provider_scripts.go), and a prompt's reference (pkg/prompt/attachserve/scripts.go). Both sides of the comparison must be identified, so a script whose owner could not be established belongs to nobody rather than to every caller the platform cannot name either.

A name is unique within its owner, so every lookup by name names an owner too: Store.Get(name) and Store.GetPersonal(owner, name) collapse into one Store.GetByName(owner, name). Two analysts may each keep a daily-sales, and an administrator addressing somebody else's script by name passes owner_email.

knowledge.ScriptsProvider moves from ScopeShared to ScopePerUser, so the router skips it entirely for a caller carrying no identity instead of querying a source that can return nothing.

An administrator can move a script to another owner

PUT /api/v1/portal/scripts/{id}/owner, on the script page administrators and owners already share, rendered for administrators alone (internal/httpserver/scripthttp/portalowner.go, ui/src/pages/scripts/ScriptOwnerTransfer.tsx).

Ownership is the whole of what a script is, so a transfer hands over what its owner sees, edits, runs, and schedules, and the script's history with them: the new owner reads the run records and dry-run accounts the previous owner produced, whose logs are free text those runs printed. That is why the action is an administrator's and not something an owner can do to hand their work along.

The move is recorded as a new applied version authored by the administrator making it. That version is the mechanism, not bookkeeping: a run presents the roles captured on the version it executes (#1403), so writing one is what re-captures the authority.

flowchart LR
    subgraph before["Before the transfer"]
        V2["version 2<br/>author: jane<br/>author_roles: analyst"] --> R1["a run presents<br/>analyst"]
    end
    subgraph after["After an administrator transfers it"]
        V3["version 3<br/>author: the administrator<br/>author_roles: admin"] --> R2["a run presents<br/>admin"]
    end
    before -->|"same source, new owner"| after
Loading

Moving a script to an administrator is therefore how it comes to run with an administrator's reach, which is the ticket's named use.

A name the receiving owner already keeps refuses the transfer with 409 and script.ErrNameTaken; names are unique within an owner, so the receiving side decides whether the move is possible. Every attempt, admitted or refused, is written to the audit log as a script_transfer_owner event of kind admin naming the script and both ends of the move. This is the first producer of audit.EventTypeAdmin, which until now existed only as a constant and an OpenAPI enum value.

The control names both ends of the move and asks for confirmation before it acts, since the person losing the script is the half an administrator can overlook. It is captured at docs/images/screenshots/light/admin-admin-script-owner-light.webp and its dark pair, and referenced from the administrator portal guide.

A prompt reference resolves for the script's owner

Any prompt may reference a script. The attach_script response carries an audience_note whenever the prompt serves somebody other than the script's owner, so the author learns what their readers will receive at the moment they make the reference rather than from a reader who received less than the prompt reads (attachserve.AudienceNote). A caller may only reference a script they can see; referencing somebody else's is refused with the shared attachment sentinel so every surface passes the sentence through verbatim.

At use, the existing behaviour stands: a caller who does not own a referenced script receives the prompt with a note that part of its automation was unavailable, never the script's name or parameters.

The promotion gate no longer consults referenced scripts (internal/platform/promptlayer/attachbind). A reference resolves for one person at every prompt scope, so widening a prompt cannot outrun it, and prompt.AttachKindScript is retired with the rule that used it.

What comes out

  • Scope, Personas, ValidateScope, scopeVisible, and scopeVisibleToAny from pkg/script; ListFilter.Scope, Personas, VisibleTo, and VisiblePersona; SearchQuery.Personas; Contract.Scope and Contract.Personas.
  • The scope and personas arguments and response fields on manage_script.
  • The "Visible to" column on the administrator's listing, and the Owner column on the owner's listing, where every script is now the reader's own. The administrator's listing keeps its Owner column and reads nobody for an ownerless script.
  • prompt.AttachKindScript and the script arm of the attachment promotion gate.

Migration 000119

Two rows that were legal apart can collide under the new index: a global daily-sales and its author's own personal daily-sales. The migration renames every row but the oldest of each group, suffixing it with the head of its id, then replaces the two partial name indexes with one unique index over (owner_email, name) and drops the columns.

A row whose owner_email is empty keeps it and so belongs to nobody: visible only to administrators, and the transfer is how it gets an owner.

Surfaces

Surface Change
manage_script scope/personas gone; list returns the caller's own scripts, or every script for an administrator
run_script unchanged, except that resolution by name is per-owner
PUT /api/v1/portal/scripts/{id}/owner new, administrators only
Script detail page new Owner section for administrators; no "Visible to" fact
My Scripts no Owner column; the summary tiles count one population
Admin Scripts no "Visible to" column

Where this differs from the ticket

The ticket lists ScriptFacetBadges.tsx among the surfaces to remove. That component renders the category and tags a script is filed under (#1369) and never rendered scope, so it stays; the scope renderers that did (audience() on the administrator's listing, visibility() on the detail page) are the ones removed.

Two rules the ticket did not mention became false and are retired rather than carried forward. manage_script delete refused a shared script in favour of deprecating it, because it might be executing on a schedule for somebody else; with one owner there is nobody else, so a delete takes the script's schedule and history with it. The separate owner gates in front of the run and schedule commands became unreachable once the read rule is owner-or-admin, so they are gone and a non-owner now reads not found rather than only the owner of a script can read its runs.

Verification

make verify passes end to end. Each gate was also run on its own command against this tree:

  • make test-realdb and make migrate-check (pg16 and pg17). The migration gate gains TestMigrationsAgainstRealPostgres_PersonalScriptCollisions, which migrates to 118, writes the rows only the old rule allowed, applies 119, and asserts the rename, the surviving name, the new index, and the dropped columns. It carries the gate's name prefix so make migrate-check runs it with no target change.
  • golangci-lint run --new-from-rev=<merge-base>: 0 issues. gosec, semgrep, CodeQL, dead-code: clean.
  • Patch coverage 96.1% of changed executable lines (threshold 80%).
  • npm run test (1332), tsc --noEmit, npm run lint, and the interactive Playwright suite, which gains a transfer flow driven against the mock server: the note, both ends named before the move, the outcome, and the re-read contract.

Docs updated: docs/scripts/security.md (assets, actors, the three tiers, the definer-rights section), docs/scripts/running.md, docs/server/tools.md, docs/server/admin-portal.md, docs/server/portal-user.md, docs/llms.txt, docs/llms-full.txt, the built-in writing-managed-scripts knowledge page, CLAUDE.md, and dev/seed.sql.

…sfer (#1404)

A script belongs to one person. Its owner sees it, edits it, runs it, and
schedules it; administrators do all four on every script; nobody else is told it
exists. Script.OwnedBy is the one definition, and every surface answers through
it: the manage_script read and edit paths, the store's list and search
predicates, the portal routes, search, fetch, and a prompt's reference. A name
is unique within its owner, so every lookup by name names an owner too.

An administrator can move a script to another owner from the script's page in
the portal (PUT /api/v1/portal/scripts/{id}/owner). Ownership is the whole of
what a script is, so the transfer hands over what its owner sees, edits, runs,
and schedules, and its history with them. The move is recorded as a new version
authored by the administrator making it, which is what re-captures the authority
a run presents: from the transfer on, a run carries that administrator's roles,
so moving a script to an administrator is how it comes to run with an
administrator's reach. A name the receiving owner already keeps refuses the
transfer. Every attempt, admitted or refused, is written to the audit log as a
script_transfer_owner event of kind admin naming the script and both ends of the
move.

A prompt may reference any script, and the reference resolves for the script's
owner alone. The response to attach_script states who it will resolve for
whenever the prompt serves anybody else, so an author learns it where they can
act on it rather than from a reader who received less than the prompt reads. A
caller may only reference a script they can see. The promotion gate no longer
consults referenced scripts, because a reference cannot be narrower than the
prompt carrying it.

Out: the scope field and its validation, the persona list, the visibility rules
built on them, the "Visible to" surface on the administrator's listing, and the
Owner column on the owner's listing, where every script is the reader's own. The
delete refusal that pointed at deprecation is retired with the audience it
protected. Migration 000119 renames a name two rows now collide on, replaces the
two partial name indexes with one over (owner_email, name), and drops the
columns. A script whose owner could not be established belongs to nobody: it is
visible only to administrators, and the transfer is how it gets an owner.
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.23810% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.98%. Comparing base (084e5e5) to head (135e8c8).

Files with missing lines Patch % Lines
internal/platform/scriptlayer/scriptlayer.go 50.00% 1 Missing and 2 partials ⚠️
pkg/prompt/attachserve/scripts.go 88.46% 3 Missing ⚠️
internal/platform/scriptstore/transfer.go 92.00% 1 Missing and 1 partial ⚠️
internal/platform/scriptlayer/schedules.go 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1409      +/-   ##
==========================================
- Coverage   90.98%   90.98%   -0.01%     
==========================================
  Files         686      689       +3     
  Lines       69082    69044      -38     
==========================================
- Hits        62852    62817      -35     
+ Misses       4077     4075       -2     
+ Partials     2153     2152       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cjimti
cjimti merged commit a910580 into main Aug 21, 2026
10 checks passed
@cjimti
cjimti deleted the feat/1404-personal-scripts branch August 21, 2026 03:12
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.

A script is personal: its owner and admins, with admin ownership transfer

1 participant