Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/architecture/identifiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<module>.<record_type>:<tail>`,
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:
Expand Down
6 changes: 6 additions & 0 deletions docs/architecture/module-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions modules/leads/src/rheo_leads/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
),
Expand Down
35 changes: 27 additions & 8 deletions modules/leads/src/rheo_leads/pipeline_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<uuid>."
),
]
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
Expand Down Expand Up @@ -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"
Expand Down
7 changes: 4 additions & 3 deletions modules/leads/src/rheo_leads/pipeline_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions modules/leads/src/rheo_leads/queue_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down
12 changes: 10 additions & 2 deletions modules/leads/src/rheo_leads/scoring/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"],
}
)
Expand Down Expand Up @@ -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
Expand All @@ -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),
}
Expand Down
39 changes: 39 additions & 0 deletions modules/leads/src/rheo_leads/short_ids.py
Original file line number Diff line number Diff line change
@@ -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()
Comment thread
rheos marked this conversation as resolved.
)


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]))]
5 changes: 5 additions & 0 deletions modules/leads/web/src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 3 additions & 15 deletions modules/leads/web/src/queue-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {
postingUrl,
readPrefsText,
savePrefs,
shortId,
jobId,
SORTS,
sourceLabel,
PREFS_KEY,
Expand Down Expand Up @@ -346,7 +346,7 @@ export function StageActions({
</a>
) : null}
{copyId ? (
<CopyButton text={str(item.id)} label="Copy opportunity ID" />
<CopyButton text={jobId(item)} label={`Copy job ID ${jobId(item)}`} />
) : null}
{onMove && target.stage_id ? (
<button
Expand Down Expand Up @@ -766,7 +766,6 @@ export function JobHeader({
const verdict = reasons[0] ?? summary;
const evidence = reasons.length > 1 ? reasons.slice(1) : [];
const source = sourceLabel(item.source);
const id = shortId(item.id);
// Client history the facts line has no room for.
const client = [formatHireRate(fields), formatReviews(fields)]
.filter(Boolean)
Expand Down Expand Up @@ -813,18 +812,6 @@ export function JobHeader({
<dd>{source}</dd>
</div>
) : null}
<div>
<dt>ID</dt>
<dd>
<CopyButton
text={str(item.id)}
label={`Copy the full ID (short ID ${id})`}
className={styles.idChip}
>
<span className={styles.idValue}>{id}</span>
</CopyButton>
</dd>
</div>
</dl>
</InfoTip>
</div>
Expand All @@ -842,6 +829,7 @@ export function JobHeader({
) : null}
<StageActions
item={item}
copyId
disabled={moving}
onMove={
submit
Expand Down
27 changes: 0 additions & 27 deletions modules/leads/web/src/queue.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -388,32 +388,6 @@
margin: 0;
color: var(--rs-color-ink);
}
.idChip {
display: inline-flex;
align-items: center;
gap: var(--rs-space-2);
max-width: 100%;
padding: var(--rs-space-1) calc(var(--rs-space-2) + var(--rs-space-1) / 4);
border: var(--rs-size-hairline) solid var(--rs-color-hairline);
border-radius: var(--rs-radius-md);
background: var(--rs-color-surface-2);
color: var(--rs-color-ink-dim);
font-family: var(--rs-font-mono);
font-size: calc(var(--rs-font-size-sm) * 0.85);
cursor: pointer;
}
.idChip:hover {
color: var(--rs-color-ink);
border-color: var(--rs-color-accent);
}
.idChip svg {
width: calc(var(--rs-space-3) + var(--rs-space-1) / 4);
height: calc(var(--rs-space-3) + var(--rs-space-1) / 4);
flex-shrink: 0;
}
.idValue {
overflow-wrap: anywhere;
}
@media (max-width: 39.9375rem) {
.job {
grid-template-columns: 1fr;
Expand Down Expand Up @@ -452,7 +426,6 @@
.card,
.chip,
.iconButton,
.idChip,
.preview {
transition:
border-color 150ms ease-out,
Expand Down
Loading
Loading