diff --git a/docs/architecture/identifiers.md b/docs/architecture/identifiers.md index 4eee8dcf..8f3b06a1 100644 --- a/docs/architecture/identifiers.md +++ b/docs/architecture/identifiers.md @@ -64,6 +64,38 @@ Rules: It never has a foreign key into another module's schema. This is how FR 11 stays checkable by a static test (criterion 26). +### Short IDs + +People see, paste and link a **short ID** instead of the full UUID (issue #345). It is the +last 8 hex characters of the record's UUID: `leads.opportunity:01a126c1-02cc-7d5b-a113-4d0fef1c1302` +shows as `ef1c1302`. The tail, not the head: a UUIDv7 starts with a millisecond timestamp, +so records made in the same minute share their leading characters, and a batch of +collected postings would all look alike. + +- The UUID stays the only identity. Primary keys, foreign keys, events, audit and export + are unchanged; there is no extra column and no backfill. +- Display lengthens a tail that two records of one type share, like a git short hash + (8, then 10, 12, and so on) within the workspace and record type. +- A reference field for a type with short IDs also accepts `.:`, + or a bare tail where the field already knows its type. It resolves by unique tail match + within that type, refuses a tail two records share with an error asking for more + characters, and keeps accepting the full reference. Operations and MCP tools resolve it + while input is validated: a typed reference field's validator (`ref_value` in + `rheo_core.refs.short`) reads the expander from the validation context that `dispatch`, + the tool facade and in-transaction calls pass. `core.runtime.run` expands typed short + forms in its `refs` the same way, so the job and its provenance hold full references. + The resolver below expands the typed form too. Past validation, handlers, audit rows + and outputs see only the full reference. A plain `*_ref: str` field that does its own + parsing takes full references until it moves to a typed field. +- A link built from a short ID can become ambiguous if a later record shares its tail; + it then asks for more characters, as a git short hash does. +- A record type opts in by declaring a `tail_lookup` in its manifest + ([owned record types](module-contract.md#owned-record-types)). Without one, a type takes + full references only. Lookup is a scan on the tail today, which is fine at a workspace's + scale; an expression index is a module migration away if it ever is not. +- An external provider's own id (a job board's posting id) is a separate field and never a + short ID. + ## Resolution under permission The core exposes one resolver: diff --git a/docs/architecture/module-contract.md b/docs/architecture/module-contract.md index c4c29ccc..326d0e87 100644 --- a/docs/architecture/module-contract.md +++ b/docs/architecture/module-contract.md @@ -90,8 +90,14 @@ RecordType delete_owned: OwnedDeleter | None # (ctx, uow, authorization) -> RemovedMemories load_for_model: RecordLoader | None # (ctx, uow, ref) -> fields | None render_for_model: ModelRenderer | None # (record, tier_policy) -> text | None + tail_lookup: TailLookup | None # (uow, length, tails) -> ids; gives the type short IDs ``` +**`tail_lookup` gives a type [short IDs](identifiers.md#short-ids)** (issue #345): the ids +of the type whose last `length` hex characters are one of `tails`. The loader registers it, +reference inputs resolve a short ID through it, and display uses it to lengthen a shared +tail. Null keeps the type on full references. + **The model-rendering pair is the redaction contract's record half, added in issue #130.** `render_for_model(record, tier_policy)` is where the [redaction contract](runtime-and-mcp.md#the-context-builder) always put it; it is a field diff --git a/modules/leads/src/rheo_leads/manifest.py b/modules/leads/src/rheo_leads/manifest.py index e5f3eac0..9b4588f1 100644 --- a/modules/leads/src/rheo_leads/manifest.py +++ b/modules/leads/src/rheo_leads/manifest.py @@ -73,6 +73,7 @@ from rheo_leads.scoring.profile import SCHEDULE_NAME as SCORE_SCHEDULE_NAME from rheo_leads.scoring.review import OPERATIONS as REVIEW_OPERATIONS from rheo_leads.scoring.view import OPERATIONS as SCORING_VIEW_OPERATIONS +from rheo_leads.short_ids import opportunity_tails from rheo_leads.web_reads import OPERATIONS as WEB_OPERATIONS MANIFEST = ModuleManifest( @@ -96,6 +97,7 @@ else None, exportable=name != "delivery_receipt", audience_field=None, + tail_lookup=opportunity_tails if name == "opportunity" else None, ) for name in RECORD_TABLES ), diff --git a/modules/leads/src/rheo_leads/pipeline_contracts.py b/modules/leads/src/rheo_leads/pipeline_contracts.py index dc1d9c8e..00621896 100644 --- a/modules/leads/src/rheo_leads/pipeline_contracts.py +++ b/modules/leads/src/rheo_leads/pipeline_contracts.py @@ -11,33 +11,47 @@ ConfigDict, Field, PlainSerializer, + ValidationInfo, model_validator, ) from rheo_contracts import ContextPurpose, RecordRef from rheo_core.redaction.tiers import SensitivityTier, Tiered +from rheo_core.refs.short import ref_value -# Explicit aliases keep audit subjects typed through the tool adapter. +# Explicit aliases keep audit subjects typed through the tool adapter. An opportunity +# also takes its short ID (#345), typed or bare, resolved while dispatch validates. OpportunityRef = Annotated[ RecordRef, - BeforeValidator(lambda v: _parse(v, "opportunity"), json_schema_input_type=str), + BeforeValidator( + lambda v, info: _parse(v, info, "opportunity"), json_schema_input_type=str + ), PlainSerializer(lambda r: r.format(), return_type=str), + Field( + description="The opportunity's short ID (8 or more hex characters, as the " + "UI shows it) or its full reference, leads.opportunity:." + ), ] ObservationRef = Annotated[ RecordRef, - BeforeValidator(lambda v: _parse(v, "observation"), json_schema_input_type=str), + BeforeValidator( + lambda v, info: _parse(v, info, "observation"), json_schema_input_type=str + ), PlainSerializer(lambda r: r.format(), return_type=str), ] PartyRef = Annotated[ RecordRef, BeforeValidator( - lambda v: _parse(v, "party", "relationships"), json_schema_input_type=str + lambda v, info: _parse(v, info, "party", "relationships"), + json_schema_input_type=str, ), PlainSerializer(lambda r: r.format(), return_type=str), ] -def _parse(v: object, kind: str, module: str = "leads") -> RecordRef: - r = RecordRef.parse(v) if isinstance(v, str) else v +def _parse( + v: object, info: ValidationInfo, kind: str, module: str = "leads" +) -> RecordRef: + r = ref_value(v, info, module, kind) if not isinstance(r, RecordRef) or r.module != module or r.record_type != kind: raise ValueError("unexpected reference") return r @@ -318,8 +332,13 @@ class ReferenceEvent(Strict): revision: int = 1 -def _deletable(v: object) -> RecordRef: - reference = RecordRef.parse(v) if isinstance(v, str) else v +def _deletable(v: object, info: ValidationInfo) -> RecordRef: + # Two types are deletable here, so a short ID must say which (no bare tail). + short = RecordRef.short(v) if isinstance(v, str) else None + if short is not None and short[0] == "leads": + reference = ref_value(v, info, "leads", short[1]) + else: + reference = RecordRef.parse(v) if isinstance(v, str) else v if ( not isinstance(reference, RecordRef) or reference.module != "leads" diff --git a/modules/leads/src/rheo_leads/pipeline_service.py b/modules/leads/src/rheo_leads/pipeline_service.py index d83e30ee..95b7553c 100644 --- a/modules/leads/src/rheo_leads/pipeline_service.py +++ b/modules/leads/src/rheo_leads/pipeline_service.py @@ -14,6 +14,7 @@ from rheo_leads import pipeline_contracts as c from rheo_leads import pipeline_evidence as e from rheo_leads.references import ref +from rheo_leads.short_ids import with_short_ids from rheo_leads.storage import pipeline as p from rheo_leads.storage import tables as t @@ -152,9 +153,9 @@ def listing(ctx: WorkspaceContext, uow: UnitOfWork, m: c.ListInput) -> c.ItemsOu .limit(m.limit) .offset(m.offset) ).mappings() - return c.ItemsOutput( - items=[{**dict(r), "ref": ref("opportunity", r["id"])} for r in rows] - ) + items = [{**dict(r), "ref": ref("opportunity", r["id"])} for r in rows] + with_short_ids(uow, items) + return c.ItemsOutput(items=items) def _link( diff --git a/modules/leads/src/rheo_leads/queue_view.py b/modules/leads/src/rheo_leads/queue_view.py index f402f2d5..e1f1b0fa 100644 --- a/modules/leads/src/rheo_leads/queue_view.py +++ b/modules/leads/src/rheo_leads/queue_view.py @@ -34,6 +34,7 @@ from rheo_leads import pipeline_contracts as c from rheo_leads.references import ref from rheo_leads.scoring import review +from rheo_leads.short_ids import with_short_ids from rheo_leads.storage import pipeline as p from rheo_leads.storage import proposals from rheo_leads.storage import tables as t @@ -330,6 +331,7 @@ def key(record: dict[str, Any]) -> tuple[UUID, int, str]: **({"hold": holds.get(identifier, [])} if m.held_only else {}), } ) + with_short_ids(uow, items) return c.ItemsOutput(items=items) diff --git a/modules/leads/src/rheo_leads/scoring/view.py b/modules/leads/src/rheo_leads/scoring/view.py index 723198e5..291a9a3e 100644 --- a/modules/leads/src/rheo_leads/scoring/view.py +++ b/modules/leads/src/rheo_leads/scoring/view.py @@ -49,6 +49,7 @@ from rheo_leads.pipeline_contracts import Empty, Strict from rheo_leads.references import ref from rheo_leads.scoring import job as scoring +from rheo_leads.short_ids import with_short_ids from rheo_leads.storage import jobfeed as jf from rheo_leads.storage import pipeline as p from rheo_leads.storage import scoring as s @@ -301,6 +302,9 @@ def _calls(uow: UnitOfWork, now: datetime) -> list[dict[str, Any]]: "ref": ref("opportunity", r["opportunity_id"]) if r["opportunity_id"] is not None else None, + "opportunity_id": str(r["opportunity_id"]) + if r["opportunity_id"] is not None + else None, "title": r["title"], } ) @@ -479,6 +483,10 @@ def view(ctx: WorkspaceContext, uow: UnitOfWork, m: Empty) -> ViewOutput: .group_by(state.c.state) ).all() ) + batches = _batches(jobs, running=scoring_now, position=position, now=now) + calls = _calls(uow, now) + with_short_ids(uow, [j for b in batches for j in b["jobs"]]) + with_short_ids(uow, calls, key="opportunity_id") per_call = _median_scoring_seconds(uow) # A job being scored is claimed, so it is never counted as claimable. queued = claimable @@ -495,8 +503,8 @@ def view(ctx: WorkspaceContext, uow: UnitOfWork, m: Empty) -> ViewOutput: "eta_seconds": round(per_call * queued) if per_call is not None and queued else None, - "batches": _batches(jobs, running=scoring_now, position=position, now=now), - "calls": _calls(uow, now), + "batches": batches, + "calls": calls, "summary": _summary(uow, now), "feed_runs": _feed_runs(uow), } diff --git a/modules/leads/src/rheo_leads/short_ids.py b/modules/leads/src/rheo_leads/short_ids.py new file mode 100644 index 00000000..c26dc8cb --- /dev/null +++ b/modules/leads/src/rheo_leads/short_ids.py @@ -0,0 +1,39 @@ +"""Short IDs for opportunities (#345): the UUID's random tail, lengthened on a clash.""" + +from collections.abc import Iterable, Sequence +from typing import Any +from uuid import UUID + +from rheo_core.refs.resolver import UnitOfWork +from rheo_core.refs.short import short_ids +from sqlalchemy import String, cast, func, select + +from rheo_leads.storage import pipeline as p + + +def opportunity_tails(uow: UnitOfWork, length: int, tails: Sequence[str]) -> list[UUID]: + """Opportunities whose last ``length`` hex characters are one of ``tails``. + + A scan, not an index: a workspace holds hundreds of opportunities, and an + expression index would be a module migration (#345). + """ + o = p.opportunity + tail = func.right(func.replace(cast(o.c.id, String), "-", ""), length) + return list( + uow.connection.execute(select(o.c.id).where(tail.in_(list(tails)))).scalars() + ) + + +def opportunity_short_ids(uow: UnitOfWork, ids: Iterable[UUID]) -> dict[UUID, str]: + return short_ids(uow, opportunity_tails, ids) + + +def with_short_ids( + uow: UnitOfWork, items: Sequence[dict[str, Any]], key: str = "id" +) -> None: + """Set ``short_id`` on each item from its opportunity id under ``key``.""" + ids = [UUID(str(i[key])) for i in items if i.get(key) is not None] + shorts = opportunity_short_ids(uow, ids) + for item in items: + if item.get(key) is not None: + item["short_id"] = shorts[UUID(str(item[key]))] diff --git a/modules/leads/web/src/data.ts b/modules/leads/web/src/data.ts index 4fdf970d..5d616ed4 100644 --- a/modules/leads/web/src/data.ts +++ b/modules/leads/web/src/data.ts @@ -87,6 +87,11 @@ export function read( /** The Job search preset's seeded id, from * rheo_leads/pipeline_common.py:JOBSEARCH_PRESET_ID (stable, set by migration). */ export const JOBSEARCH_PRESET_ID = "019bf0b0-0000-7000-8000-000000000002"; +/** What a detail link carries for an opportunity: its short ID (#345) when the + * server gave one, else the full reference. Both resolve. */ +export function detailRef(record: Row): string { + return str(record.short_id) || str(record.ref); +} /** A pipeline's record reference. Here, not in a client module, because server * screens call it while rendering. */ export function pipelineRef(id: string): string { diff --git a/modules/leads/web/src/queue-view.tsx b/modules/leads/web/src/queue-view.tsx index af725ff3..c7fbd0dd 100644 --- a/modules/leads/web/src/queue-view.tsx +++ b/modules/leads/web/src/queue-view.tsx @@ -36,7 +36,7 @@ import { postingUrl, readPrefsText, savePrefs, - shortId, + jobId, SORTS, sourceLabel, PREFS_KEY, @@ -346,7 +346,7 @@ export function StageActions({ ) : null} {copyId ? ( - + ) : null} {onMove && target.stage_id ? (