From a01d6116ff70751440ac1fdbb62a9e2a461eaee2 Mon Sep 17 00:00:00 2001 From: rheos <7797788+rheos@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:57:16 -0700 Subject: [PATCH 1/5] Short IDs: resolve UUID tails in reference inputs (#345) A reference field now also takes .: or a bare tail, resolved by unique tail match through a per-type lookup a manifest record type declares. Dispatch, the tool facade and in-transaction calls pass the lookup through pydantic's validation context; the core resolver expands the typed form too. Leads gives opportunities short IDs and returns short_id on queue, list and scoring rows. Co-Authored-By: Claude Opus 5.5 --- modules/leads/src/rheo_leads/manifest.py | 2 + .../src/rheo_leads/pipeline_contracts.py | 31 ++- .../leads/src/rheo_leads/pipeline_service.py | 7 +- modules/leads/src/rheo_leads/queue_view.py | 2 + modules/leads/src/rheo_leads/scoring/view.py | 12 +- modules/leads/src/rheo_leads/short_ids.py | 39 ++++ .../contracts/src/rheo_contracts/__init__.py | 2 + packages/contracts/src/rheo_contracts/refs.py | 45 ++++ packages/core/src/rheo_core/modules/loader.py | 9 + .../core/src/rheo_core/modules/manifest.py | 10 + .../core/src/rheo_core/operations/dispatch.py | 3 +- .../src/rheo_core/operations/tool_facade.py | 9 +- .../src/rheo_core/operations/transaction.py | 5 +- packages/core/src/rheo_core/refs/resolver.py | 37 +++- packages/core/src/rheo_core/refs/short.py | 204 ++++++++++++++++++ 15 files changed, 398 insertions(+), 19 deletions(-) create mode 100644 modules/leads/src/rheo_leads/short_ids.py create mode 100644 packages/core/src/rheo_core/refs/short.py 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..592dc4ff 100644 --- a/modules/leads/src/rheo_leads/pipeline_contracts.py +++ b/modules/leads/src/rheo_leads/pipeline_contracts.py @@ -11,33 +11,43 @@ 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), ] 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 +328,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/packages/contracts/src/rheo_contracts/__init__.py b/packages/contracts/src/rheo_contracts/__init__.py index 63ff1c13..e696b8c2 100644 --- a/packages/contracts/src/rheo_contracts/__init__.py +++ b/packages/contracts/src/rheo_contracts/__init__.py @@ -34,6 +34,7 @@ from rheo_contracts.purposes import ContextPurpose from rheo_contracts.refs import ( RESERVED_MODULE_SEGMENT, + SHORT_ID_LENGTH, RecordRef, RecordRefMalformed, is_reserved_module, @@ -84,6 +85,7 @@ "ISOLATION_ENFORCED", "RESERVED_INPUT_FIELDS", "RESERVED_MODULE_SEGMENT", + "SHORT_ID_LENGTH", "Actor", "ActorKind", "AdapterSpawn", diff --git a/packages/contracts/src/rheo_contracts/refs.py b/packages/contracts/src/rheo_contracts/refs.py index 04fd7dc7..6ce4526d 100644 --- a/packages/contracts/src/rheo_contracts/refs.py +++ b/packages/contracts/src/rheo_contracts/refs.py @@ -25,6 +25,21 @@ Segment = Annotated[str, StringConstraints(pattern=rf"^{_SEGMENT_PATTERN}$")] +SHORT_ID_LENGTH = 8 +"""How many hex characters a short ID starts at: the UUID's random tail (#345). + +A UUIDv7 begins with a millisecond timestamp, so records made in the same minute share +their head; the tail is random. Display lengthens it, like a git short hash, when two +records of a type share it. +""" +_TAIL = re.compile(rf"[0-9a-f]{{{SHORT_ID_LENGTH},32}}") + + +def _clean_tail(value: str) -> str | None: + candidate = value.strip().lower().replace("-", "") + return candidate if _TAIL.fullmatch(candidate) else None + + RESERVED_MODULE_SEGMENT = "core" """The module segment reserved for core-owned records (operations, approvals). @@ -78,6 +93,36 @@ def parse(cls, value: str) -> Self: module=match["module"], record_type=match["record_type"], id=record_id ) + @staticmethod + def short(value: str) -> tuple[str, str, str] | None: + """``(module, record_type, tail)`` for ``.:``. + + The typed short form (#345). A tail is 8 to 32 hex characters, read without + dashes or case. This only reads the string: finding the one record a tail names + needs the workspace's storage (``rheo_core.refs.short``). + """ + match = _REFERENCE.fullmatch(value.strip()) + if match is None: + return None + tail = _clean_tail(match["record_id"]) + return None if tail is None else (match["module"], match["record_type"], tail) + + @classmethod + def tail(cls, value: str, module: str, record_type: str) -> str | None: + """The tail ``value`` names for ``.``, or ``None``. + + Accepts the typed short form and, since the type is known, a bare tail. + """ + short = cls.short(value) + if short is not None: + return short[2] if short[:2] == (module, record_type) else None + return _clean_tail(value) + + @staticmethod + def short_id(identifier: UUID, length: int = SHORT_ID_LENGTH) -> str: + """The last ``length`` hex characters of ``identifier``.""" + return identifier.hex[-length:] + def format(self) -> str: """The canonical string form, round-tripping through :meth:`parse`.""" return f"{self.module}.{self.record_type}:{self.id}" diff --git a/packages/core/src/rheo_core/modules/loader.py b/packages/core/src/rheo_core/modules/loader.py index 530b7abd..68e0a002 100644 --- a/packages/core/src/rheo_core/modules/loader.py +++ b/packages/core/src/rheo_core/modules/loader.py @@ -134,6 +134,7 @@ from rheo_core.redaction.policy import exclude_types_spec from rheo_core.redaction.registry import RENDERINGS, ModelRendering from rheo_core.refs.resolver import RESOLVERS, ResolverRegistry +from rheo_core.refs.short import register_tail_lookup from rheo_core.settings.deployment import read_deployment_value from rheo_core.settings.schema import REGISTRY as SETTINGS_REGISTRY from rheo_core.settings.schema import SettingTypeMismatch, ValueType @@ -899,6 +900,14 @@ def _register( deletions.register_participant( manifest.module_id, participant.record_types, participant.handler ) + for owned_type in manifest.record_types: + if owned_type.tail_lookup is not None: + register_tail_lookup( + manifest.module_id, + owned_type.name, + owned_type.tail_lookup, + origin=manifest.module_id, + ) for owned_type in manifest.record_types: # The redaction contract's record half, unconditional and on the process-global # table for the owned-delete pair's reason: the context builder reads that one diff --git a/packages/core/src/rheo_core/modules/manifest.py b/packages/core/src/rheo_core/modules/manifest.py index 3943dc4c..cdd5853d 100644 --- a/packages/core/src/rheo_core/modules/manifest.py +++ b/packages/core/src/rheo_core/modules/manifest.py @@ -72,6 +72,7 @@ from rheo_core.redaction.tiers import SensitivityTier as SensitivityTier from rheo_core.redaction.tiers import restricted_fields from rheo_core.refs.resolver import RecordResolver +from rheo_core.refs.short import TailLookup as _TailLookup from rheo_core.settings.schema import KeySpec, Scope, ValueType from rheo_core.storage.backend import UnitOfWork from rheo_core.work.kinds import JobHandler @@ -487,6 +488,10 @@ def _model_rendering_callable(value: object) -> object: """``(record, tier_policy) -> text | None``: the ratified ``render_for_model``; see :class:`~rheo_core.redaction.render.ModelRenderer`.""" +ShortIdLookup = Annotated[_TailLookup | None, PlainValidator(_model_rendering_callable)] +"""``(uow, length, tails) -> ids``: gives the type short IDs (#345); see +:data:`~rheo_core.refs.short.TailLookup`.""" + def _export_callable(value: object) -> object: """The duck check :class:`ExportDeclaration`'s pair applies. @@ -672,6 +677,10 @@ class RecordType(BaseModel): (:func:`check_sensitivity`): a type with a tier map or its own renderer must declare a loader, and a loader on a type with neither is refused as a declaration nothing reads. + + **``tail_lookup`` gives the type short IDs** (issue #345): people see and paste the + UUID's random tail, and reference inputs resolve it through this lookup. ``None`` + keeps the type on full references only. """ model_config = ConfigDict(frozen=True) @@ -686,6 +695,7 @@ class RecordType(BaseModel): delete_owned: OwnedDeleter = None load_for_model: RecordLoader = None render_for_model: ModelRenderer = None + tail_lookup: ShortIdLookup = None @model_validator(mode="after") def _the_owned_delete_pair_is_whole(self) -> Self: diff --git a/packages/core/src/rheo_core/operations/dispatch.py b/packages/core/src/rheo_core/operations/dispatch.py index 02ed9ee5..58b61c6a 100644 --- a/packages/core/src/rheo_core/operations/dispatch.py +++ b/packages/core/src/rheo_core/operations/dispatch.py @@ -190,6 +190,7 @@ class is not ``READ`` the sink installed for the *owning module* is resolved onc OperationRegistry, RegisteredOperation, ) +from rheo_core.refs.short import validation_context from rheo_core.storage.backend import HandlerUnitOfWork, StorageRefusal, UnitOfWork from rheo_core.storage.routing import open_unit_of_work from rheo_core.work.jobs import has_job_for_operation @@ -1081,7 +1082,7 @@ def dispatch( ) try: model_input = declaration.input_model.model_validate( - {} if payload is None else payload + {} if payload is None else payload, context=validation_context(ctx) ) except ValidationError as exc: _audit_alone(ctx, audit, outcome=AUDIT_REFUSED) diff --git a/packages/core/src/rheo_core/operations/tool_facade.py b/packages/core/src/rheo_core/operations/tool_facade.py index d08b93b6..f536718c 100644 --- a/packages/core/src/rheo_core/operations/tool_facade.py +++ b/packages/core/src/rheo_core/operations/tool_facade.py @@ -127,6 +127,7 @@ # but not ``rheo_core.redaction`` (``tests/test_mcp_boundary.py``). from rheo_core.redaction.render import WITHHELD_REASON as WITHHELD_REASON from rheo_core.redaction.render import render_output +from rheo_core.refs.short import validation_context from rheo_core.settings import CORE_ORIGIN from rheo_core.tokens.sets import ( DISCOVER_THEN_CALL_OPERATION, @@ -389,7 +390,9 @@ def _refused(state: str, detail: str) -> OperationOutcome: def _validated( - declaration: ToolDeclaration, arguments: Mapping[str, object] + ctx: WorkspaceContext, + declaration: ToolDeclaration, + arguments: Mapping[str, object], ) -> BaseModel | OperationOutcome: """``arguments`` as the tool's own input model, or the refusal that stops it. @@ -413,7 +416,7 @@ def _validated( f"{declaration.name} does not accept argument(s) {unknown}", ) try: - return model.model_validate(dict(arguments)) + return model.model_validate(dict(arguments), context=validation_context(ctx)) except ValidationError as exc: return _refused(INPUT_INVALID, _describe_validation_error(exc)) @@ -603,7 +606,7 @@ def _call( ) declaration = registered.declaration started = time.monotonic() - validated = _validated(declaration, arguments) + validated = _validated(ctx, declaration, arguments) if not isinstance(validated, OperationOutcome): refused_write = _mask_token_write(declaration, arguments) if refused_write is not None: diff --git a/packages/core/src/rheo_core/operations/transaction.py b/packages/core/src/rheo_core/operations/transaction.py index 8d380138..38bf7444 100644 --- a/packages/core/src/rheo_core/operations/transaction.py +++ b/packages/core/src/rheo_core/operations/transaction.py @@ -20,6 +20,7 @@ from rheo_core.operations.refusals import OperationRefused from rheo_core.operations.registry import REGISTRY, OperationRegistry from rheo_core.refs.resolver import UnitOfWork +from rheo_core.refs.short import validation_context def call_in_transaction( @@ -48,7 +49,9 @@ def call_in_transaction( if audit is None and declaration.safety_class is not SafetyClass.READ: raise OperationRefused("audit_sink_missing", "operation audit sink unavailable") try: - model = declaration.input_model.model_validate(payload) + model = declaration.input_model.model_validate( + payload, context=validation_context(ctx, uow) + ) except ValidationError: raise OperationRefused("input_invalid", "invalid service input") from None from rheo_core.modules.readiness import require_ready diff --git a/packages/core/src/rheo_core/refs/resolver.py b/packages/core/src/rheo_core/refs/resolver.py index 7dd8ed64..24c44277 100644 --- a/packages/core/src/rheo_core/refs/resolver.py +++ b/packages/core/src/rheo_core/refs/resolver.py @@ -57,6 +57,7 @@ REFERENCE_MALFORMED: Final = "reference_malformed" UNRESOLVABLE: Final = "unresolvable" NOT_FOUND: Final = "not_found" +REFERENCE_AMBIGUOUS: Final = "reference_ambiguous" _SEGMENT: Final = re.compile(r"[a-z][a-z0-9_]*") @@ -165,7 +166,10 @@ def resolve_in( try: parsed = RecordRef.parse(ref) except RecordRefMalformed: - return Unavailable(str(ref), REFERENCE_MALFORMED) + expanded = _expand_short(ref, ctx, uow) + if isinstance(expanded, Unavailable): + return expanded + parsed = expanded except TypeError: return Unavailable(str(ref), REFERENCE_MALFORMED) reference = parsed.format() @@ -186,6 +190,37 @@ def resolve_in( return resolver(ctx, uow, parsed) +def _expand_short( + ref: str, ctx: WorkspaceContext, uow: UnitOfWork +) -> RecordRef | Unavailable: + """A ``.:`` short reference (#345), by unique tail match. + + Malformed when the string is not that form or the type has no short IDs; the + module's own refusal when it is disabled or not ready; ``not_found`` for no match, + ``reference_ambiguous`` for a tail two records share. Readiness is checked before + the lookup touches the module's tables, and again by the caller for the expanded + reference. + """ + from rheo_core.modules.readiness import require_ready + from rheo_core.operations.refusals import OperationRefused + from rheo_core.refs.short import AMBIGUOUS, TAILS, expand_in + + short = RecordRef.short(ref) + if short is None or TAILS.lookup(short[0], short[1]) is None: + return Unavailable(ref, REFERENCE_MALFORMED) + if not is_reserved_module(short[0]) and short[0] not in ctx.enabled_modules: + return Unavailable(ref, MODULE_DISABLED) + try: + require_ready(uow.connection, short[0]) + except OperationRefused: + return Unavailable(ref, "module_unavailable") + try: + return expand_in(uow, *short) + except ValueError as exc: + reason = REFERENCE_AMBIGUOUS if str(exc) == AMBIGUOUS else NOT_FOUND + return Unavailable(ref, reason) + + def resolve( ref: RecordRef | str, ctx: object, *, registry: ResolverRegistry = RESOLVERS ) -> RecordHead | Unavailable: diff --git a/packages/core/src/rheo_core/refs/short.py b/packages/core/src/rheo_core/refs/short.py new file mode 100644 index 00000000..ab080db1 --- /dev/null +++ b/packages/core/src/rheo_core/refs/short.py @@ -0,0 +1,204 @@ +"""Short IDs (#345): a record's UUID tail, shown to people and accepted back. + +A UUIDv7 stays the only identity. People see the last 8 hex characters of it (the head +is a millisecond timestamp, so records made in the same minute share it; the tail is +random). Like a git short hash, display lengthens a tail that two records of one type +share, and every place that takes a reference also takes ``.:``, or +a bare tail where the type is known, resolving it by a unique tail match within that +type. An ambiguous tail is refused, never guessed. + +A module opts a record type in by registering a :data:`TailLookup`. Input validation +reaches storage through pydantic's validation context: ``dispatch``, the tool façade +and in-transaction calls pass :func:`validation_context`, and a reference field's +validator calls :func:`ref_value`. Without that context a short form is refused, so a +model validated anywhere else accepts only full references, exactly as before. + +Imported by module path, never re-exported from ``rheo_core.refs`` (the same import +cycle ``resolver.py`` documents). +""" + +from collections.abc import Callable, Iterable, Sequence +from typing import Any, Final +from uuid import UUID + +from pydantic import ValidationInfo +from rheo_contracts import ( + SHORT_ID_LENGTH, + RecordRef, + RecordRefMalformed, + WorkspaceContext, +) + +from rheo_core.operations.refusals import OperationRefused, RegistrationRefused +from rheo_core.operations.registry import check_origin +from rheo_core.storage.backend import StorageRefusal, UnitOfWork +from rheo_core.storage.routing import open_unit_of_work + +TailLookup = Callable[[UnitOfWork, int, Sequence[str]], Sequence[UUID]] +"""The ids of one record type whose last ``length`` hex characters are in ``tails``.""" + +SHORT_REFS: Final = "rheo.short_refs" +"""The validation-context key :class:`ShortRefExpander` travels under.""" + +AMBIGUOUS: Final = "this short ID matches more than one record; give more characters" +NOT_FOUND: Final = "no record has this short ID" +UNAVAILABLE: Final = "short IDs cannot be looked up now; use the full reference" + + +class TailLookups: + """The record-type → tail lookup table. One instance is exported as ``TAILS``.""" + + def __init__(self) -> None: + self._lookups: dict[tuple[str, str], TailLookup] = {} + + def register( + self, module_id: str, record_type: str, lookup: TailLookup, *, origin: str + ) -> None: + key = f"{module_id}.{record_type}" + check_origin(origin, module_id, name=key) + existing = self._lookups.get((module_id, record_type)) + if existing is not None and existing is not lookup: + raise RegistrationRefused(key, "a tail lookup is already registered") + self._lookups[(module_id, record_type)] = lookup + + def lookup(self, module_id: str, record_type: str) -> TailLookup | None: + return self._lookups.get((module_id, record_type)) + + +TAILS: Final = TailLookups() + + +def register_tail_lookup( + module_id: str, + record_type: str, + lookup: TailLookup, + *, + origin: str, + registry: TailLookups = TAILS, +) -> None: + """Give ``.`` short IDs; see ``TailLookups.register``.""" + registry.register(module_id, record_type, lookup, origin=origin) + + +def expand_in( + uow: UnitOfWork, + module_id: str, + record_type: str, + tail: str, + *, + registry: TailLookups = TAILS, +) -> RecordRef: + """The one record of the type whose UUID ends in ``tail``, or ``ValueError``.""" + lookup = registry.lookup(module_id, record_type) + if lookup is None: + raise ValueError("this record type takes full references only") + found = set(lookup(uow, len(tail), [tail])) + if not found: + raise ValueError(NOT_FOUND) + if len(found) > 1: + raise ValueError(AMBIGUOUS) + return RecordRef(module=module_id, record_type=record_type, id=found.pop()) + + +def shortest_tail(identifier: UUID, among: Iterable[UUID]) -> str: + """``identifier``'s shortest tail no other id in ``among`` ends with: 8, 10, 12…""" + own = identifier.hex + rivals = [ + other.hex + for other in among + if other != identifier + and other.hex[-SHORT_ID_LENGTH:] == own[-SHORT_ID_LENGTH:] + ] + length = SHORT_ID_LENGTH + while length < len(own) and any(r.endswith(own[-length:]) for r in rivals): + length += 2 + return own[-length:] + + +def short_ids( + uow: UnitOfWork, lookup: TailLookup, ids: Iterable[UUID] +) -> dict[UUID, str]: + """Each id's display short ID, lengthened where its type has a shared tail. + + ``lookup`` is the type's own :data:`TailLookup`. One call over the 8-character + tails finds every record that could collide: two ids that share a longer tail share + the 8-character one too. + """ + wanted = list(dict.fromkeys(ids)) + if not wanted: + return {} + tails = sorted({RecordRef.short_id(i) for i in wanted}) + among = set(lookup(uow, SHORT_ID_LENGTH, tails)) | set(wanted) + return {i: shortest_tail(i, among) for i in wanted} + + +class ShortRefExpander: + """Resolves short references while an operation's input is validated. + + ``dispatch`` validates before it opens the work transaction, so without ``uow`` the + expander opens its own short read; inside a transaction it reuses that one. It is + only consulted for a value that is not a full reference. + """ + + def __init__( + self, + ctx: WorkspaceContext, + uow: UnitOfWork | None = None, + *, + registry: TailLookups = TAILS, + ) -> None: + self._ctx = ctx + self._uow = uow + self._registry = registry + + def expand(self, module_id: str, record_type: str, tail: str) -> RecordRef: + if self._uow is not None: + return self._expand(self._uow, module_id, record_type, tail) + try: + uow = open_unit_of_work(self._ctx) + except StorageRefusal: + raise ValueError(UNAVAILABLE) from None + with uow: + return self._expand(uow, module_id, record_type, tail) + + def _expand( + self, uow: UnitOfWork, module_id: str, record_type: str, tail: str + ) -> RecordRef: + from rheo_core.modules.readiness import require_ready + + try: + require_ready(uow.connection, module_id) + except OperationRefused: + raise ValueError(UNAVAILABLE) from None + return expand_in(uow, module_id, record_type, tail, registry=self._registry) + + +def validation_context( + ctx: object, uow: UnitOfWork | None = None +) -> dict[str, object] | None: + """The pydantic context that lets reference fields accept short IDs.""" + if not isinstance(ctx, WorkspaceContext): + return None + return {SHORT_REFS: ShortRefExpander(ctx, uow)} + + +def ref_value( + value: Any, info: ValidationInfo, module_id: str, record_type: str +) -> Any: + """A reference field's before-validator: a full reference parses, a short one + (typed, or bare since the field knows its type) expands through the context. + + Anything else is returned unchanged, for the field's own check to refuse. + """ + if not isinstance(value, str): + return value + try: + return RecordRef.parse(value) + except RecordRefMalformed: + pass + tail = RecordRef.tail(value, module_id, record_type) + context = info.context if isinstance(info.context, dict) else {} + expander = context.get(SHORT_REFS) + if tail is None or not isinstance(expander, ShortRefExpander): + raise ValueError("invalid reference") + return expander.expand(module_id, record_type, tail) From 703846d9f1ddf442bd812baec3567045b3db7721 Mon Sep 17 00:00:00 2001 From: rheos <7797788+rheos@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:02:46 -0700 Subject: [PATCH 2/5] Short IDs in Leads links and a visible Copy job ID button (#345) Detail links on the queue, review queue, list and scoring pages carry the short ID. Queue cards and the job detail header get a Copy job ID icon button that copies it; the ID chip behind the info tip goes. Tests cover the timestamp-prefix trap, lengthening on a shared tail, ambiguous-tail refusal, full references, the tool facade and the resolver. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/identifiers.md | 27 ++++ docs/architecture/module-contract.md | 6 + .../src/rheo_leads/pipeline_contracts.py | 4 + modules/leads/web/src/data.ts | 5 + modules/leads/web/src/queue-view.tsx | 18 +-- modules/leads/web/src/queue.module.css | 27 ---- modules/leads/web/src/queue.test.tsx | 37 +++-- modules/leads/web/src/queue.ts | 12 +- .../web/src/review-interactions.test.tsx | 2 +- modules/leads/web/src/scoring.test.tsx | 22 +++ modules/leads/web/src/screens.tsx | 20 +-- tests/postgres/test_short_ids_leads.py | 127 ++++++++++++++++++ tests/test_short_ids.py | 64 +++++++++ 13 files changed, 308 insertions(+), 63 deletions(-) create mode 100644 tests/postgres/test_short_ids_leads.py create mode 100644 tests/test_short_ids.py diff --git a/docs/architecture/identifiers.md b/docs/architecture/identifiers.md index 4eee8dcf..bab9cc21 100644 --- a/docs/architecture/identifiers.md +++ b/docs/architecture/identifiers.md @@ -64,6 +64,33 @@ 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. +- Every place that accepts a reference 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 (the reference field's validator reads + `rheo_core.refs.short` through the validation context `dispatch` passes); the resolver + below expands the typed form. Either way the handler, the audit row and every output + carry the full reference. +- 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/pipeline_contracts.py b/modules/leads/src/rheo_leads/pipeline_contracts.py index 592dc4ff..00621896 100644 --- a/modules/leads/src/rheo_leads/pipeline_contracts.py +++ b/modules/leads/src/rheo_leads/pipeline_contracts.py @@ -26,6 +26,10 @@ 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, 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 ? (