feat(store): copy a convention's role field into its role column at startup (PLAN-3598) (#2092)

Lead ruling on PLAN-3598: a convention's role is its agent-role COLUMN, the
validated reference that bootstrap labels convention_index with, not the
free-text `role` field the skill told agents to filter on. Conventions that
carry only the field would drop out of every role-scoped read.

BackfillConventionRoles runs at startup on every boot and selects by content.
It is additive only, each rule pinned by a test:
- a field value that is exactly a live role's slug fills an EMPTY column;
- a set column is never overwritten;
- the field is never cleared;
- any other value (a wrong case, a role that does not exist) is reported and
  left alone.

A copy is a visible role change, so unlike BUG-2788's silent id backfill it
moves the seq. Delta sync keys on seq; a silent write would hide the role
from local-first clients until a full re-fetch. Each copy is one conditional
UPDATE, guarded on the column still being empty and on the seq as read, so
a concurrent writer is never overwritten. Its activity row is attributed to
the system, carries the {prev_seq, seq} stamp, and emits no webhook or
outbox event; the seq-site guard classifies the site as STAMPED. Each copy
logs itself. Invalid values never self-heal, so each boot logs one summary
line plus details for the first 20 invalid rows only (lead ruling).

SKILL.md now filters and assigns convention roles with `--role`, the column,
instead of `--field role=`.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
This commit is contained in:
xarmian
2026-10-11 22:17:03 -04:00
committed by GitHub
parent 8eb4519fe7
commit bdea49af64
5 changed files with 512 additions and 2 deletions
+18
View File
@@ -233,6 +233,24 @@ func serveCmd() *cobra.Command {
slog.Info("Attachment bidi filename backfill complete", "rows_rewritten", bb.RowsRewritten)
}
// Copy a convention's free-text `role` field into its empty
// agent-role column where it names a live role (PLAN-3598): the
// column is what role-scoped reads use. Each copy logs itself; a
// value naming no role never self-heals, so it is summarized per
// boot with details for the first few rows only. Non-fatal: an
// uncopied convention is just unscoped, as before.
if cr, err := s.BackfillConventionRoles(); err != nil {
slog.Warn("convention role backfill failed; non-fatal", "error", err,
"copied_before_error", cr.Copied)
} else {
slog.Info("Convention role backfill complete", "copied", cr.Copied,
"invalid", cr.Invalid, "raced", cr.Raced)
for _, inv := range cr.InvalidSample {
slog.Warn("convention role field names no role in its workspace; left as is",
"workspace_id", inv.WorkspaceID, "item_id", inv.ItemID, "value", inv.Value)
}
}
// Persist an id on every implementation note / decision entry
// stored without a usable, unique one (BUG-2788), so a timeline
// entry's identity no longer depends on its position in the array.
+257
View File
@@ -0,0 +1,257 @@
package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/PerpetualSoftware/pad/internal/artifact"
"github.com/PerpetualSoftware/pad/internal/collections"
"github.com/PerpetualSoftware/pad/internal/models"
)
// PLAN-3598, lead ruling: a convention's role is its agent-role COLUMN, the
// validated reference bootstrap labels convention_index with, not the
// free-text `role` field the skill docs used to filter on. Conventions that
// carry only the field would drop out of every role-scoped read, so their
// field value is copied into the column, once, where it names a live role.
//
// Additive only, each pinned by a test in convention_role_backfill_test.go:
// - a field value that is exactly a live role's slug in the item's
// workspace is copied into an EMPTY column;
// - a set column is never overwritten, whatever the field says;
// - the field is never cleared or rewritten;
// - a value that names no live role is reported, not guessed at.
//
// A copy is a visible role change, so unlike BUG-2788's silent id backfill it
// moves the item's seq: delta sync (/items/changes?since=<seq>) keys on seq,
// and a silent write would leave every local-first client showing no role
// until a full re-fetch. Each copy is one conditional UPDATE, guarded on the
// column still being empty and on the seq as read, so a concurrent writer is
// never overwritten (that row is picked up by the next boot). Its activity
// row is attributed to the system, carries the seq stamp, and emits no
// webhook or outbox event.
//
// It runs on EVERY boot and selects by content, like the bidi filename
// backfill: a copied row no longer matches (its column is set), and an
// invalid value never self-heals, so it is reported on every boot. That is
// why the log is a per-boot summary with details for the first
// conventionRoleInvalidLogLimit invalid rows, while each copy is logged in
// full by the run that makes it.
// conventionRoleInvalidLogLimit caps the per-boot detail lines for values
// that name no live role (lead ruling).
const conventionRoleInvalidLogLimit = 20
// ConventionRoleInvalid is one convention whose `role` field names no live
// role in its workspace.
type ConventionRoleInvalid struct {
WorkspaceID string
ItemID string
Value string
}
// BackfillConventionRolesResult reports what the backfill did, for the
// startup log line.
type BackfillConventionRolesResult struct {
Copied int
// Invalid counts every invalid row this boot; InvalidSample holds the
// first conventionRoleInvalidLogLimit of them.
Invalid int
InvalidSample []ConventionRoleInvalid
// Raced counts rows whose column or seq changed between the read and the
// write; the next boot reconsiders them.
Raced int
}
// BackfillConventionRoles copies a convention's `role` field into its empty
// agent-role column where the field names a live role. See the comment
// above for the rules.
func (s *Store) BackfillConventionRoles() (*BackfillConventionRolesResult, error) {
res := &BackfillConventionRolesResult{}
wsRows, err := s.db.Query(s.q(`SELECT id FROM workspaces WHERE deleted_at IS NULL ORDER BY id`))
if err != nil {
return res, fmt.Errorf("convention role backfill: list workspaces: %w", err)
}
var workspaceIDs []string
for wsRows.Next() {
var id string
if err := wsRows.Scan(&id); err != nil {
_ = wsRows.Close()
return res, fmt.Errorf("convention role backfill: scan workspace: %w", err)
}
workspaceIDs = append(workspaceIDs, id)
}
if err := wsRows.Close(); err != nil {
return res, fmt.Errorf("convention role backfill: list workspaces: %w", err)
}
for _, wsID := range workspaceIDs {
if err := s.backfillConventionRolesInWorkspace(wsID, res); err != nil {
return res, err
}
}
return res, nil
}
type conventionRoleCandidate struct {
id, value string
seq int64
}
func (s *Store) backfillConventionRolesInWorkspace(wsID string, res *BackfillConventionRolesResult) error {
traited, err := s.ListTraitedCollections(wsID)
if err != nil {
return fmt.Errorf("convention role backfill: traits for %s: %w", wsID, err)
}
coll := collections.FindByArtifactKind(traited, string(artifact.KindConvention))
if coll == nil {
return nil
}
fieldsExpr := "fields"
if s.dialect.Driver() == DriverPostgres {
fieldsExpr = "fields::text"
}
rows, err := s.db.Query(s.q(`SELECT id, seq, `+fieldsExpr+` FROM items
WHERE workspace_id = ? AND collection_id = ? AND agent_role_id IS NULL AND deleted_at IS NULL
ORDER BY id`), wsID, coll.ID)
if err != nil {
return fmt.Errorf("convention role backfill: select in %s: %w", wsID, err)
}
var cands []conventionRoleCandidate
for rows.Next() {
var c conventionRoleCandidate
var fields sql.NullString
if err := rows.Scan(&c.id, &c.seq, &fields); err != nil {
_ = rows.Close()
return fmt.Errorf("convention role backfill: scan in %s: %w", wsID, err)
}
c.value = conventionRoleFieldValue(fields.String)
if c.value != "" {
cands = append(cands, c)
}
}
if err := rows.Close(); err != nil {
return fmt.Errorf("convention role backfill: select in %s: %w", wsID, err)
}
for _, c := range cands {
roleID, roleName, err := s.liveRoleBySlug(wsID, c.value)
if err != nil {
return err
}
if roleID == "" {
res.Invalid++
if len(res.InvalidSample) < conventionRoleInvalidLogLimit {
res.InvalidSample = append(res.InvalidSample, ConventionRoleInvalid{WorkspaceID: wsID, ItemID: c.id, Value: c.value})
}
continue
}
newSeq, err := s.copyConventionRole(wsID, c, roleID, roleName)
if errors.Is(err, errConventionRoleRaced) {
res.Raced++
continue
}
if err != nil {
return err
}
res.Copied++
slog.Info("convention role backfill: copied role field into the role column",
"workspace_id", wsID, "item_id", c.id, "role", c.value, "role_id", roleID,
"prev_seq", c.seq, "seq", newSeq)
}
return nil
}
// conventionRoleFieldValue is the item's `role` field as a trimmed string,
// or "" when it is absent, empty, not a string, or the blob does not decode.
func conventionRoleFieldValue(fields string) string {
if fields == "" {
return ""
}
var m map[string]any
if err := json.Unmarshal([]byte(fields), &m); err != nil {
return ""
}
v, _ := m["role"].(string)
return strings.TrimSpace(v)
}
// liveRoleBySlug resolves an exact role slug in the workspace. A role id, a
// display name or a differently cased slug is not a match: the field is free
// text, and only a value that is unambiguously a slug is copied.
func (s *Store) liveRoleBySlug(wsID, slug string) (id, name string, err error) {
err = s.db.QueryRow(s.q(`SELECT id, name FROM agent_roles WHERE workspace_id = ? AND slug = ?`), wsID, slug).Scan(&id, &name)
if errors.Is(err, sql.ErrNoRows) {
return "", "", nil
}
if err != nil {
return "", "", fmt.Errorf("convention role backfill: resolve role %q in %s: %w", slug, wsID, err)
}
return id, name, nil
}
var errConventionRoleRaced = errors.New("convention changed since it was read")
// copyConventionRole sets the column and moves the seq in one conditional
// UPDATE, then records the system activity row in the same transaction.
func (s *Store) copyConventionRole(wsID string, c conventionRoleCandidate, roleID, roleName string) (int64, error) {
tx, err := s.db.Begin()
if err != nil {
return 0, fmt.Errorf("convention role backfill: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := s.acquireWorkspaceSeqLock(tx, wsID); err != nil {
return 0, err
}
r, err := tx.Exec(s.q(`UPDATE items SET agent_role_id = ?, seq = `+nextWorkspaceSeqSubquery+`, updated_at = ?
WHERE id = ? AND workspace_id = ? AND agent_role_id IS NULL AND seq = ? AND deleted_at IS NULL`),
roleID, wsID, now(), c.id, wsID, c.seq)
if err != nil {
return 0, fmt.Errorf("convention role backfill: update %s: %w", c.id, err)
}
n, err := r.RowsAffected()
if err != nil {
return 0, fmt.Errorf("convention role backfill: rows affected %s: %w", c.id, err)
}
if n != 1 {
return 0, errConventionRoleRaced
}
var newSeq int64
if err := tx.QueryRow(s.q(`SELECT seq FROM items WHERE id = ?`), c.id).Scan(&newSeq); err != nil {
return 0, fmt.Errorf("convention role backfill: read seq %s: %w", c.id, err)
}
meta, err := json.Marshal(map[string]any{
"changes": "role: — → " + roleName,
"backfill": "convention_role_field",
ActivityMetaPrevSeq: c.seq,
ActivityMetaSeq: newSeq,
})
if err != nil {
return 0, err
}
if _, err := s.createActivityQ(tx, activityForConventionRoleBackfill(wsID, c.id, string(meta))); err != nil {
return 0, fmt.Errorf("convention role backfill: activity %s: %w", c.id, err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("convention role backfill: commit %s: %w", c.id, err)
}
return newSeq, nil
}
func activityForConventionRoleBackfill(wsID, itemID, meta string) models.Activity {
return models.Activity{
WorkspaceID: wsID,
DocumentID: itemID,
Action: "updated",
Actor: "system",
Source: "system",
Metadata: meta,
}
}
@@ -0,0 +1,234 @@
package store
import (
"encoding/json"
"errors"
"fmt"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// PLAN-3598 role backfill. Each of the lead's terms is pinned here: a valid
// field value is copied into an EMPTY column; a set column is never
// overwritten; the field is never cleared; a value naming no live role is
// reported, not copied. Plus the seq bump, the system activity row, the
// per-boot invalid cap, and the conditional write.
type crbFixture struct {
s *Store
ws *models.Workspace
conventions, tasks *models.Collection
implementer, rev *models.AgentRole
}
func newCRBFixture(t *testing.T, slug string) *crbFixture {
t.Helper()
s := testStore(t)
ws := newTestWorkspace(t, s, slug)
if err := s.SeedCollectionsFromTemplate(ws.ID, "startup"); err != nil {
t.Fatalf("seed: %v", err)
}
conv, err := s.GetCollectionBySlug(ws.ID, "conventions")
if err != nil {
t.Fatalf("conventions collection: %v", err)
}
tasks, err := s.GetCollectionBySlug(ws.ID, "tasks")
if err != nil {
t.Fatalf("tasks collection: %v", err)
}
impl, err := s.CreateAgentRole(ws.ID, models.AgentRoleCreate{Name: "Implementer", Slug: "implementer"})
if err != nil {
t.Fatalf("role: %v", err)
}
rev, err := s.CreateAgentRole(ws.ID, models.AgentRoleCreate{Name: "Reviewer", Slug: "reviewer"})
if err != nil {
t.Fatalf("role: %v", err)
}
return &crbFixture{s: s, ws: ws, conventions: conv, tasks: tasks, implementer: impl, rev: rev}
}
func (f *crbFixture) item(t *testing.T, coll *models.Collection, title, role string, column *string) *models.Item {
t.Helper()
fields := map[string]any{"status": "active", "trigger": "always"}
if role != "" {
fields["role"] = role
}
b, _ := json.Marshal(fields)
it, err := f.s.CreateItem(f.ws.ID, coll.ID, models.ItemCreate{Title: title, Fields: string(b), AgentRoleID: column})
if err != nil {
t.Fatalf("create %s: %v", title, err)
}
return it
}
func (f *crbFixture) row(t *testing.T, id string) (roleID string, seq int64, role string) {
t.Helper()
it, err := f.s.GetItem(id)
if err != nil {
t.Fatalf("get %s: %v", id, err)
}
if it.AgentRoleID != nil {
roleID = *it.AgentRoleID
}
return roleID, it.Seq, conventionRoleFieldValue(it.Fields)
}
func TestBackfillConventionRoles_AdditiveOnly(t *testing.T) {
t.Parallel()
f := newCRBFixture(t, "crb-additive")
revID := f.rev.ID
valid := f.item(t, f.conventions, "valid field, empty column", "implementer", nil)
set := f.item(t, f.conventions, "column already set", "implementer", &revID)
cased := f.item(t, f.conventions, "wrong case", "Implementer", nil)
ghost := f.item(t, f.conventions, "no such role", "ghost", nil)
none := f.item(t, f.conventions, "no field", "", nil)
task := f.item(t, f.tasks, "a task, not a convention", "implementer", nil)
_, validSeq0, _ := f.row(t, valid.ID)
_, setSeq0, _ := f.row(t, set.ID)
_, taskSeq0, _ := f.row(t, task.ID)
res, err := f.s.BackfillConventionRoles()
if err != nil {
t.Fatalf("backfill: %v", err)
}
if res.Copied != 1 || res.Invalid != 2 || res.Raced != 0 {
t.Fatalf("result = %+v, want Copied 1, Invalid 2, Raced 0", res)
}
// A valid field value fills the EMPTY column, moves the seq, keeps the field.
roleID, seq, field := f.row(t, valid.ID)
if roleID != f.implementer.ID {
t.Errorf("valid: column = %q, want %q", roleID, f.implementer.ID)
}
if seq <= validSeq0 {
t.Errorf("valid: seq %d did not move past %d", seq, validSeq0)
}
if field != "implementer" {
t.Errorf("valid: field = %q, want it left as implementer", field)
}
// A set column is never overwritten, and its seq does not move.
roleID, seq, field = f.row(t, set.ID)
if roleID != revID || seq != setSeq0 || field != "implementer" {
t.Errorf("set column: column %q seq %d field %q, want %q %d implementer", roleID, seq, field, revID, setSeq0)
}
// Invalid values are reported, not copied, and the field stays.
for _, it := range []*models.Item{cased, ghost} {
roleID, _, field = f.row(t, it.ID)
if roleID != "" {
t.Errorf("%s: column = %q, want empty", it.Title, roleID)
}
if field == "" {
t.Errorf("%s: field was cleared", it.Title)
}
}
got := map[string]string{}
for _, inv := range res.InvalidSample {
got[inv.ItemID] = inv.Value
}
if got[cased.ID] != "Implementer" || got[ghost.ID] != "ghost" || len(got) != 2 {
t.Errorf("InvalidSample = %+v, want the wrong-case and ghost rows", res.InvalidSample)
}
// A convention with no field and a task with one are not candidates.
if roleID, _, _ = f.row(t, none.ID); roleID != "" {
t.Errorf("no field: column = %q", roleID)
}
if roleID, seq, _ = f.row(t, task.ID); roleID != "" || seq != taskSeq0 {
t.Errorf("task: column %q seq %d, want untouched", roleID, seq)
}
// A second boot copies nothing, moves no seq, and reports the invalid
// rows again (they never self-heal).
_, validSeq1, _ := f.row(t, valid.ID)
res2, err := f.s.BackfillConventionRoles()
if err != nil {
t.Fatalf("second backfill: %v", err)
}
if res2.Copied != 0 || res2.Invalid != 2 {
t.Fatalf("second run = %+v, want Copied 0, Invalid 2", res2)
}
if _, seq, _ = f.row(t, valid.ID); seq != validSeq1 {
t.Errorf("second run moved seq %d -> %d", validSeq1, seq)
}
}
func TestBackfillConventionRoles_ActivityIsSystemAndStamped(t *testing.T) {
t.Parallel()
f := newCRBFixture(t, "crb-activity")
it := f.item(t, f.conventions, "gets a role", "implementer", nil)
_, prev, _ := f.row(t, it.ID)
if _, err := f.s.BackfillConventionRoles(); err != nil {
t.Fatalf("backfill: %v", err)
}
_, seq, _ := f.row(t, it.ID)
var actor, source, action, meta string
err := f.s.db.QueryRow(f.s.q(`SELECT actor, source, action, metadata FROM activities
WHERE document_id = ? AND actor = 'system'`), it.ID).Scan(&actor, &source, &action, &meta)
if err != nil {
t.Fatalf("system activity row: %v", err)
}
if source != "system" || action != "updated" {
t.Errorf("activity source %q action %q, want system / updated", source, action)
}
gotSeq, gotPrev, _, broken, ok := seqStamp(meta)
if !ok || broken || gotSeq != seq || gotPrev != prev {
t.Errorf("stamp = seq %d prev %d ok %v broken %v, want seq %d prev %d (meta %s)", gotSeq, gotPrev, ok, broken, seq, prev, meta)
}
}
func TestBackfillConventionRoles_ConditionalOnColumnAndSeq(t *testing.T) {
t.Parallel()
f := newCRBFixture(t, "crb-race")
it := f.item(t, f.conventions, "raced", "implementer", nil)
_, seq, _ := f.row(t, it.ID)
// A stale seq: a writer moved the row after the read.
_, err := f.s.copyConventionRole(f.ws.ID, conventionRoleCandidate{id: it.ID, value: "implementer", seq: seq - 1}, f.implementer.ID, "Implementer")
if !errors.Is(err, errConventionRoleRaced) {
t.Fatalf("stale seq: err = %v, want errConventionRoleRaced", err)
}
// A column set after the read, at the same seq.
if _, err := f.s.db.Exec(f.s.q(`UPDATE items SET agent_role_id = ? WHERE id = ?`), f.rev.ID, it.ID); err != nil {
t.Fatal(err)
}
_, err = f.s.copyConventionRole(f.ws.ID, conventionRoleCandidate{id: it.ID, value: "implementer", seq: seq}, f.implementer.ID, "Implementer")
if !errors.Is(err, errConventionRoleRaced) {
t.Fatalf("set column: err = %v, want errConventionRoleRaced", err)
}
roleID, seqAfter, _ := f.row(t, it.ID)
if roleID != f.rev.ID || seqAfter != seq {
t.Errorf("after refused writes: column %q seq %d, want %q %d", roleID, seqAfter, f.rev.ID, seq)
}
var n int
if err := f.s.db.QueryRow(f.s.q(`SELECT COUNT(*) FROM activities WHERE document_id = ? AND actor = 'system'`), it.ID).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("a refused write left %d system activity rows", n)
}
}
func TestBackfillConventionRoles_InvalidSampleIsCapped(t *testing.T) {
t.Parallel()
f := newCRBFixture(t, "crb-cap")
for i := 0; i < conventionRoleInvalidLogLimit+5; i++ {
f.item(t, f.conventions, fmt.Sprintf("ghost %d", i), "ghost", nil)
}
res, err := f.s.BackfillConventionRoles()
if err != nil {
t.Fatalf("backfill: %v", err)
}
if res.Invalid != conventionRoleInvalidLogLimit+5 {
t.Errorf("Invalid = %d, want %d", res.Invalid, conventionRoleInvalidLogLimit+5)
}
if len(res.InvalidSample) != conventionRoleInvalidLogLimit {
t.Errorf("InvalidSample has %d rows, want the cap %d", len(res.InvalidSample), conventionRoleInvalidLogLimit)
}
}
@@ -34,6 +34,7 @@ var seqMovingSites = map[string]string{
"items.go:bumpStructuralLinkSourceTx": "BREAKS: a link change bumps the source item",
"items.go:moveItemWithPreCheckOnce": "BREAKS: collection move",
"items_cross_workspace_copy.go:archiveItemForCopyTx": "BREAKS: archive-source on a cross-workspace move",
"convention_role_backfill.go:copyConventionRole": "STAMPED: the startup role backfill (PLAN-3598) writes a system activity entry with {prev_seq, seq} in the same transaction",
}
func TestPLAN3578_EverySeqMovingSiteIsClassified(t *testing.T) {
+2 -2
View File
@@ -177,7 +177,7 @@ If a role is active, load **both** role-specific and global conventions (convent
```bash
# Template — replace <trigger> with a concrete value from the workspace's schema:
pad item list conventions --field trigger=<trigger> --field status=active --field role=<role> --format json --full # Role-specific
pad item list conventions --field trigger=<trigger> --field status=active --role <role> --format json --full # Role-specific
pad item list conventions --field trigger=<trigger> --field status=active --format json --full # All (includes global)
pad item list playbooks --field trigger=<trigger> --field status=active --format json --full
@@ -351,7 +351,7 @@ Run the **onboard** invokable playbook — see the **Onboarding** entry under Na
7. **Keep it practical.** Size each item so it's a single meaningful unit of work — what "meaningful" means depends on the workspace (one branch/PR for code, one interview round for hiring, one research question for research). Ideas should be actionable. Docs should be concise. Check the workspace's conventions for domain-specific sizing rules.
8. **Attribution matters.** Items and comments you create are stamped `created_by: agent` and `source: cli` automatically — but the agent half only works if the CLI can tell it is being run by an agent. It detects Claude Code on its own; under any other harness, set `PAD_AGENT=<name>` in the environment (or `agent_name` in `.pad.toml`) or your writes will be recorded as the human whose credentials you are using. Whatever you send (trimmed, control characters removed, at most 64 characters) is DISPLAYED on the surfaces that store it — the activity feed, the dashboard's recent activity, activity entries on an item's timeline, the admin console's audit and per-user activity views — so a specific name (`reviewer`, `nightly-triage`) is more use to a reader than a generic client id. Comments show it too (read through the activity each comment links to); versions and note/decision entries record only that an agent acted, not which one. Note this is self-declared, not proof: it makes the trail honest, it does not make it verifiable, so never treat `created_by` on a comment as evidence that a human said something.
9. **Follow project conventions.** Always load and follow active conventions before performing work. They are project-specific rules that override your defaults. When a role is active, load both role-specific and global conventions.
10. **Learn and teach.** When the user corrects your behavior or teaches you a project-specific rule, offer to save it as a convention: "Should I save this as a project convention so future agents follow it too?" Use `pad item create convention "Title" --field trigger=<inferred> --field scope=<inferred> --field priority=should --stdin` with an appropriate trigger inferred from the context. If the correction is role-specific, add `--field role=<slug>`.
10. **Learn and teach.** When the user corrects your behavior or teaches you a project-specific rule, offer to save it as a convention: "Should I save this as a project convention so future agents follow it too?" Use `pad item create convention "Title" --field trigger=<inferred> --field scope=<inferred> --field priority=should --stdin` with an appropriate trigger inferred from the context. If the correction is role-specific, add `--role <slug>`.
11. **Role context is per-conversation.** If roles exist, ask which role the user is working as on first invocation. Remember it for the session. Auto-filter queries and suggest assignments accordingly. Never block on role — if the user says "no role" or the workspace has no roles, work normally.
## Anything Else