design record: an authorship field, recorded and in neither id

The person is identified by an email address. A handle may be chosen later
and does not replace it: the handle is a display name, the address is the id.

Excluded from input_id and build_id. input_id answers whether two records
describe the same part as specified, so two people specifying the same part
must collide there; hashing the author would make identical parts claim to be
different designs. The guarantee is structural rather than careful -- the ids
are computed from the resolved parameter set and the author never enters it.

That exclusion narrows the one-way door it was scheduled against but does not
close it. A record regenerated later with the author filled in keeps its
input_id, but build_id covers the code revision and the Shapely and GEOS
versions, and boolean results on near-degenerate geometry can shift between
GEOS releases -- the F-034 mechanism. Regenerate after a GEOS bump and you have
attribution under a different build identity beside a printed part nobody can
tie to either. Recoverable, not free. Priority order in HANDOFF section 4 is
reversed accordingly: this before STL export, so the first coupon off the
printer carries its author.

The rendered line carries its own limit -- "(self-declared, unverified)".
Nothing checks that the address belongs to whoever typed it, and a bare email
in a field called author reads as identity to someone finding the file in five
years. The record is meant to outlive everyone present, so it states what it
knows. When an authenticated identity exists the qualifier changes and the
distinction stays legible. Same discipline as Provenance.describe(), a separate
type: provenance is about measurement, and design_record imports nothing from
mechcomp, which is what keeps a failure in the record off the build path.

parse() gets its own branch because authorship is neither input nor output and
inherits neither rule. It recovers the address and never the verification: a
text file cannot attest to its own verification, so reading a verified record
back as self-declared understates the claim, which is the only safe direction.

REVISION stays 8.0.0. The field reaches no geometry.

547 passed. The 529 are unchanged and no oracle case moved. The eighteen new
assertions were mutation-tested with PYTHONDONTWRITEBYTECODE=1 and caches
cleared between runs: leaking the author into input_canonical, deafening the
parser, trusting the adjective, dropping the qualifier, and turning an empty
author into an empty claim each fail the suite.
This commit is contained in:
2026-09-12 03:56:50 -05:00
parent a7162bc1d5
commit 48d566574e
5 changed files with 409 additions and 9 deletions
+132 -1
View File
@@ -41,6 +41,23 @@ TWO IDENTITIES, BECAUSE THEY ANSWER DIFFERENT QUESTIONS
Keeping them apart matters. When the code changes and the input_id still Keeping them apart matters. When the code changes and the input_id still
matches, you know the intent is unchanged and the geometry MIGHT have moved matches, you know the intent is unchanged and the geometry MIGHT have moved
-- which is exactly the moment to check, rather than to assume either way. -- which is exactly the moment to check, rather than to assume either way.
AUTHORSHIP IS A THIRD KIND, AND IT IS IN NEITHER HASH
Authorship is not an input and not an output. It does not regenerate a
model and it does not confirm one; it says who asked for it.
It is excluded from both ids deliberately. ``input_id`` answers "is this
the same part as specified", so two people specifying the same part must
collide there -- hashing the author would make identical parts claim to be
different designs, which is the opposite of what the id is for.
One consequence worth stating, because it bounds how much that exclusion
buys: a record regenerated later with the author filled in keeps the same
``input_id``, but ``build_id`` covers the code revision and the Shapely and
GEOS versions, and boolean results on near-degenerate geometry can shift
between GEOS releases. Regenerate after a GEOS bump and you get attribution
under a different build identity beside a part nobody can now tie to
either. Attribution is recoverable, not free.
""" """
from __future__ import annotations from __future__ import annotations
@@ -73,6 +90,67 @@ VERIFY_KEYS: Tuple[str, ...] = (
) )
# ---------------------------------------------------------------------------
# Authorship
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Author:
"""
Who asked for a model. The email address is the identity.
WHY A SEPARATE TYPE RATHER THAN ``stock.Provenance``
Provenance answers "where did this dimension come from" and its fields
-- source, recorded, note -- are about measurement. Authorship answers
"who claims to have made this", which is a different question with a
different failure mode: a wrong dimension is caught by a caliper, a
wrong identity is caught by nothing. The discipline is borrowed; the
type is not. This module also imports nothing from ``mechcomp``, which
is what keeps a failure in the record off the build path.
WHY THE RENDERED LINE CARRIES ITS OWN LIMIT
An unverified email in a field called ``author`` reads as identity to
anyone who finds the file in five years, and the record is designed to
outlive everyone present. So the line says what it knows:
author theron@kane-il.us (self-declared, unverified)
Nothing here checks that the address belongs to the person who typed
it, and nothing should pretend otherwise. When an authenticated
identity exists, ``method`` names the mechanism and the qualifier
changes -- the distinction stays legible either way.
A HANDLE IS NOT THE IDENTITY
A person may later choose a handle. It is a display name and it does
not replace the address: ``handle`` is decoration, ``email`` is the id.
"""
email: str = ""
handle: str = ""
verified: bool = False
method: str = "" # how it was verified, when it was
@property
def present(self) -> bool:
return bool(self.email.strip())
def qualifier(self) -> str:
"""The limit on the claim, in the record's own words."""
if not self.verified:
return "self-declared, unverified"
how = self.method.strip()
return "verified: %s" % how if how else "verified"
def describe(self) -> str:
"""One line for the design record that ships with a model."""
if not self.present:
return ""
name = self.email.strip()
if self.handle.strip():
name = "%s (%s)" % (name, self.handle.strip())
return "%s (%s)" % (name, self.qualifier())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Canonical form # Canonical form
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -151,6 +229,11 @@ class DesignRecord:
and the same record remade tomorrow describe the same part, and if the and the same record remade tomorrow describe the same part, and if the
timestamp entered the identity then regenerating a design would always timestamp entered the identity then regenerating a design would always
appear to produce a different one. appear to produce a different one.
``author`` is excluded for a different reason -- see the module docstring.
The two exclusions are not the same rule and should not be collapsed: a
timestamp is noise, an author is a fact about a person that simply is not
part of what the part IS.
""" """
family: str family: str
@@ -167,6 +250,7 @@ class DesignRecord:
verification: Dict[str, object] = field(default_factory=dict) verification: Dict[str, object] = field(default_factory=dict)
generated: str = "" generated: str = ""
note: str = "" note: str = ""
author: Optional[Author] = None
# -- identity ---------------------------------------------------------- # -- identity ----------------------------------------------------------
@@ -222,6 +306,8 @@ class DesignRecord:
"family %s" % self.family, "family %s" % self.family,
"profile %s" % self.profile, "profile %s" % self.profile,
] ]
if self.author is not None and self.author.present:
lines.append("author %s" % self.author.describe())
if self.generated: if self.generated:
lines.append("generated %s (excluded from both ids)" % self.generated) lines.append("generated %s (excluded from both ids)" % self.generated)
if self.note: if self.note:
@@ -264,11 +350,24 @@ class DesignRecord:
Only the fields that reproduce a design are recovered -- family, profile, Only the fields that reproduce a design are recovered -- family, profile,
parameters, build identity. Verification values are outputs and are parameters, build identity. Verification values are outputs and are
deliberately not fed back in as inputs. deliberately not fed back in as inputs.
AUTHORSHIP GETS ITS OWN BRANCH, ON PURPOSE.
It is neither input nor output, so it inherits neither rule. It is
recovered, because dropping the author when a record is read and
rewritten would silently strip attribution -- but it is recovered
UNVERIFIED, whatever the text claimed.
That is deliberate and it is the only safe direction. A text file
cannot attest to its own verification: anyone can type
"(verified: ...)" into one. Reading a verified record back as
self-declared understates the claim; doing the reverse would let a
forged line launder itself into a trusted one.
""" """
family = profile = "" family = profile = ""
code_revision = generator_revision = "unknown" code_revision = generator_revision = "unknown"
params: Dict[str, object] = {} params: Dict[str, object] = {}
toolchain: Dict[str, str] = {} toolchain: Dict[str, str] = {}
author: Optional[Author] = None
section = "" section = ""
for raw in text.splitlines(): for raw in text.splitlines():
@@ -298,12 +397,21 @@ class DesignRecord:
family = line.split(None, 1)[1].strip() family = line.split(None, 1)[1].strip()
elif line.startswith("profile "): elif line.startswith("profile "):
profile = line.split(None, 1)[1].strip() profile = line.split(None, 1)[1].strip()
elif line.startswith("author "):
# The address is the first token after the label and can hold
# no whitespace, so everything beyond it is the qualifier and
# any handle -- read for the reader's benefit, not trusted.
rest = line.split(None, 1)[1].strip()
email = rest.split(None, 1)[0] if rest else ""
if email:
author = Author(email=email)
return DesignRecord( return DesignRecord(
family=family, profile=profile, params=params, family=family, profile=profile, params=params,
code_revision=code_revision, code_revision=code_revision,
generator_revision=generator_revision, generator_revision=generator_revision,
toolchain=toolchain, toolchain=toolchain,
author=author,
) )
@@ -384,6 +492,23 @@ def toolchain_versions() -> Dict[str, str]:
return dict(out) return dict(out)
def author_from(value: object) -> Optional[Author]:
"""
Accept what a caller has: an ``Author``, a bare address, or nothing.
A bare string is the ordinary case -- the composer has one text field --
and it becomes an unverified self-declaration, because that is all a typed
address is. Empty and whitespace are ``None`` rather than an empty author,
so an absent author renders as nothing at all instead of an empty claim.
"""
if value is None:
return None
if isinstance(value, Author):
return value if value.present else None
text = str(value).strip()
return Author(email=text) if text else None
def record_for(family: str, profile: str, def record_for(family: str, profile: str,
defaults: Mapping[str, object], defaults: Mapping[str, object],
params: Optional[Mapping[str, object]] = None, params: Optional[Mapping[str, object]] = None,
@@ -393,12 +518,17 @@ def record_for(family: str, profile: str,
code_revision: str = "unknown", code_revision: str = "unknown",
generator_revision: str = "unknown", generator_revision: str = "unknown",
generated: str = "", generated: str = "",
note: str = "") -> DesignRecord: note: str = "",
author: object = None) -> DesignRecord:
""" """
Build a record for one design. Build a record for one design.
``defaults`` comes from the family (``three_x.FAMILY.defaults``), ``params`` ``defaults`` comes from the family (``three_x.FAMILY.defaults``), ``params``
is the override set, and the merge reproduces what ``assemble`` does. is the override set, and the merge reproduces what ``assemble`` does.
``author`` never touches ``params``, and that placement is the whole
guarantee: the hashes are computed from the resolved parameter set, so an
author that is not in it cannot reach either id.
""" """
verification: Dict[str, object] = {} verification: Dict[str, object] = {}
if report: if report:
@@ -416,4 +546,5 @@ def record_for(family: str, profile: str,
verification=verification, verification=verification,
generated=generated, generated=generated,
note=note, note=note,
author=author_from(author),
) )
+12 -2
View File
@@ -12,6 +12,12 @@ Parameter names are the OpenSCAD ones, unchanged. ``params`` carries only the
overrides for a case; everything else comes from the family's declared defaults, overrides for a case; everything else comes from the family's declared defaults,
which is why those defaults are part of the port rather than part of the test which is why those defaults are part of the port rather than part of the test
harness. harness.
``author`` is a separate argument rather than a parameter, and that is the
point. Parameters are hashed into ``input_id``; an author is not part of what a
part IS, so it travels beside the parameter set and never inside it. Callers
that do not supply one -- the oracle harness among them -- get exactly the
behaviour they had before.
""" """
from __future__ import annotations from __future__ import annotations
@@ -32,7 +38,8 @@ FAMILIES: Dict[str, Family] = {
def build(family: str, profile: str, def build(family: str, profile: str,
params: Optional[Mapping[str, object]] = None) -> Result: params: Optional[Mapping[str, object]] = None,
author: object = None) -> Result:
""" """
Build one cross-section and return its report. Build one cross-section and return its report.
@@ -41,6 +48,9 @@ def build(family: str, profile: str,
does. An unknown *family* is a caller error rather than a rejection, so it does. An unknown *family* is a caller error rather than a rejection, so it
raises ``ValueError``: silently rejecting it would make a missing generator raises ``ValueError``: silently rejecting it would make a missing generator
indistinguishable from a case the reference declined. indistinguishable from a case the reference declined.
``author`` accepts an email address, a ``design_record.Author``, or nothing.
It reaches the design record and nothing else.
""" """
fam = FAMILIES.get(family) fam = FAMILIES.get(family)
if fam is None: if fam is None:
@@ -48,4 +58,4 @@ def build(family: str, profile: str,
"unknown family %r; this port implements %s" "unknown family %r; this port implements %s"
% (family, ", ".join(sorted(FAMILIES))) % (family, ", ".join(sorted(FAMILIES)))
) )
return assemble(fam, profile, params or {}) return assemble(fam, profile, params or {}, author)
+16 -3
View File
@@ -39,6 +39,11 @@ from mechcomp.geom import (
# Generator revision. Qualification attestations bind to this, so any change # Generator revision. Qualification attestations bind to this, so any change
# that alters emitted geometry must bump it. See geom/report.py for what is # that alters emitted geometry must bump it. See geom/report.py for what is
# published. # published.
#
# Authorship did not bump it. The field is recorded beside the model and
# reaches no geometry, so a part built before it existed and the same part
# built after are the same bytes -- which is exactly what an unbumped
# generator revision asserts.
REVISION = "8.0.0" REVISION = "8.0.0"
Params = Mapping[str, object] Params = Mapping[str, object]
@@ -57,7 +62,7 @@ class Family:
def _design_record(family: Family, profile_type: str, params: Params, def _design_record(family: Family, profile_type: str, params: Params,
geo: Geo, rep: Dict[str, object]): geo: Geo, rep: Dict[str, object], author: object = None):
""" """
The record that ships with this model. The record that ships with this model.
@@ -67,6 +72,11 @@ def _design_record(family: Family, profile_type: str, params: Params,
was built rather than what was asked for -- the two agree today and the was built rather than what was asked for -- the two agree today and the
record should keep saying so if they ever stop. record should keep saying so if they ever stop.
``author`` arrives from the caller and is passed straight through. It is
deliberately NOT merged into ``params``: the ids are computed from the
resolved parameter set, so keeping it out of that mapping is what keeps it
out of both hashes.
Imported lazily. ``mechcomp.design_record`` is a leaf that imports Imported lazily. ``mechcomp.design_record`` is a leaf that imports
nothing from mechcomp, and keeping the import here rather than at module nothing from mechcomp, and keeping the import here rather than at module
scope means a failure in the record can never prevent a part being built. scope means a failure in the record can never prevent a part being built.
@@ -95,6 +105,7 @@ def _design_record(family: Family, profile_type: str, params: Params,
fit=[fit.describe()], fit=[fit.describe()],
code_revision=code_revision(), code_revision=code_revision(),
generator_revision=REVISION, generator_revision=REVISION,
author=author,
) )
@@ -138,7 +149,8 @@ def rect_outline(w: float, h: float) -> List[tuple]:
# The build # The build
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def assemble(family: Family, profile_type: str, params: Params) -> Result: def assemble(family: Family, profile_type: str, params: Params,
author: object = None) -> Result:
p = {**family.defaults, **dict(params or {})} p = {**family.defaults, **dict(params or {})}
geo = make_geo(p) geo = make_geo(p)
@@ -170,7 +182,8 @@ def assemble(family: Family, profile_type: str, params: Params) -> Result:
) )
return Result(report=rep, section=c.section, members=c.members, geo=geo, return Result(report=rep, section=c.section, members=c.members, geo=geo,
record=_design_record(family, profile_type, params, geo, rep)) record=_design_record(family, profile_type, params, geo, rep,
author))
def geometry_checks(p: Params) -> List[Check]: def geometry_checks(p: Params) -> List[Check]:
+28 -3
View File
@@ -14,6 +14,12 @@ WHAT IT SHOWS
what you are looking at is visible while you tune, not discovered what you are looking at is visible while you tune, not discovered
afterwards. afterwards.
The author field is part of that. It is an email address, it is never
checked, and the record says so on the line it appears on. Nothing here
persists it: it lives in the page for as long as the page is open, because
remembering it across visits would be the first half of a persistence
layer that does not exist yet.
WHAT IT DOES NOT DO WHAT IT DOES NOT DO
It does not export STL, and it does not persist anything. It is a viewer and It does not export STL, and it does not persist anything. It is a viewer and
a control panel over ``build()``. Rejections are shown as messages rather a control panel over ``build()``. Rejections are shown as messages rather
@@ -95,7 +101,8 @@ def profile_params(family, profile: str) -> List[Tuple[str, List[str]]]:
def build_payload(family_name: str, profile: str, def build_payload(family_name: str, profile: str,
overrides: Dict[str, Any]) -> Dict[str, Any]: overrides: Dict[str, Any],
author: str = "") -> Dict[str, Any]:
from mechcomp.profiles import ProfileRejected, build from mechcomp.profiles import ProfileRejected, build
family = families()[family_name] family = families()[family_name]
@@ -126,7 +133,8 @@ def build_payload(family_name: str, profile: str,
} }
try: try:
result = build(family=family_name, profile=profile, params=typed) result = build(family=family_name, profile=profile, params=typed,
author=author)
except ProfileRejected as exc: except ProfileRejected as exc:
payload["ok"] = False payload["ok"] = False
payload["message"] = str(exc) payload["message"] = str(exc)
@@ -170,6 +178,8 @@ PAGE = """<!doctype html>
input,select { font:inherit; font-size:12.5px; padding:4px 7px; input,select { font:inherit; font-size:12.5px; padding:4px 7px;
border:1px solid var(--line); border-radius:5px; background:#fff; width:100%; } border:1px solid var(--line); border-radius:5px; background:#fff; width:100%; }
input:focus,select:focus { outline:2px solid var(--accent); outline-offset:-1px; } input:focus,select:focus { outline:2px solid var(--accent); outline-offset:-1px; }
label.wide { grid-template-columns:1fr; gap:3px; }
.hint { font-size:11px; color:#7b8794; margin:-2px 0 10px; }
.figure { background:#fff; border:1px solid var(--line); border-radius:9px; .figure { background:#fff; border:1px solid var(--line); border-radius:9px;
padding:18px; display:inline-block; min-width:300px; min-height:200px; } padding:18px; display:inline-block; min-width:300px; min-height:200px; }
.bad { background:#fff4ed; border:1px solid #f0c3a2; border-radius:9px; .bad { background:#fff4ed; border:1px solid #f0c3a2; border-radius:9px;
@@ -198,6 +208,13 @@ PAGE = """<!doctype html>
<label><span>family</span><select id="family"></select></label> <label><span>family</span><select id="family"></select></label>
<label><span>profile</span><select id="profile"></select></label> <label><span>profile</span><select id="profile"></select></label>
</fieldset> </fieldset>
<fieldset>
<legend>Author</legend>
<label class="wide"><span>email</span><input id="author" type="email"
autocomplete="email" placeholder="you@example.com"></label>
<p class="hint">Recorded with the design, never checked, and not stored
anywhere. The record says it is self-declared.</p>
</fieldset>
<div id="controls"></div> <div id="controls"></div>
</div> </div>
<div class="stage"> <div class="stage">
@@ -220,6 +237,8 @@ async function refresh(sendValues) {
const q = new URLSearchParams(); const q = new URLSearchParams();
q.set("family", state.family); q.set("family", state.family);
q.set("profile", state.profile); q.set("profile", state.profile);
const author = document.getElementById("author").value.trim();
if (author) q.set("author", author);
if (sendValues) for (const [k,v] of Object.entries(state.values)) q.set(k, v); if (sendValues) for (const [k,v] of Object.entries(state.values)) q.set(k, v);
const data = await (await fetch("/api/build?" + q.toString())).json(); const data = await (await fetch("/api/build?" + q.toString())).json();
@@ -286,6 +305,8 @@ function applyToggles() {
for (const id of ["t-material","t-cavity","t-stock"]) for (const id of ["t-material","t-cavity","t-stock"])
document.getElementById(id).onchange = applyToggles; document.getElementById(id).onchange = applyToggles;
document.getElementById("author").onchange = () => refresh(true);
refresh(false); refresh(false);
</script></body></html> </script></body></html>
""" """
@@ -311,8 +332,12 @@ class Handler(BaseHTTPRequestHandler):
q = {k: v[0] for k, v in parse_qs(parsed.query).items()} q = {k: v[0] for k, v in parse_qs(parsed.query).items()}
family = q.pop("family", "3x") family = q.pop("family", "3x")
profile = q.pop("profile", "Y") profile = q.pop("profile", "Y")
# Popped rather than left in the mapping. Everything remaining is
# treated as a build parameter, and an author that survived into
# that mapping would be one rename away from reaching the ids.
author = q.pop("author", "")
try: try:
payload = build_payload(family, profile, q) payload = build_payload(family, profile, q, author)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
payload = {"ok": False, payload = {"ok": False,
"message": "%s: %s" % (type(exc).__name__, exc), "message": "%s: %s" % (type(exc).__name__, exc),
+221
View File
@@ -0,0 +1,221 @@
"""
The authorship field: what it records, and what it must never touch.
WHY A SEPARATE FILE RATHER THAN LINES IN ``test_design_record.py``
Delivery here is a tarball that expands over the tree, so every file in the
set is shipped whole. Reproducing a 16 kB test module in order to add
assertions to it risks corrupting sixteen kilobytes of working tests to add
one; a new file overwrites nothing and cannot damage what is already green.
If these belong beside the others later, moving them is a local edit made
in the container.
WHAT THESE TESTS ARE ACTUALLY FOR
One claim carries the whole design: an author reaches the record and
neither id. Most of what follows exists to make that claim falsifiable
rather than merely stated -- every negative assertion is paired with a
positive control showing the same assertion could have failed (F-027).
"""
from __future__ import annotations
import pytest
from mechcomp.design_record import (
Author,
DesignRecord,
author_from,
record_for,
)
DEFAULTS = {
"strap_width_mm": 15.875,
"strap_thickness_mm": 0.508,
"fit_clearance_mm": 0.25,
"bundle_count": 1,
}
EMAIL = "theron@kane-il.us"
def make(author=None, params=None) -> DesignRecord:
return record_for(
family="3x", profile="Y",
defaults=DEFAULTS, params=params or {},
code_revision="deadbee", generator_revision="8.0.0",
author=author,
)
# ---------------------------------------------------------------------------
# The claim: authorship is in neither hash
# ---------------------------------------------------------------------------
def test_author_changes_neither_id():
without = make()
with_one = make(author=EMAIL)
assert with_one.input_id == without.input_id
assert with_one.build_id == without.build_id
def test_a_different_author_changes_neither_id():
"""Not just presence -- identity. Two people, one part, one design id."""
a = make(author=EMAIL)
b = make(author="someone.else@example.org")
assert a.input_id == b.input_id
assert a.build_id == b.build_id
def test_the_ids_are_sensitive_to_something():
"""
The positive control for both tests above.
Without this, an ``input_id`` that ignored its inputs entirely would pass
them, and the suite would be asserting that a constant equals itself.
"""
base = make(author=EMAIL)
moved = make(author=EMAIL, params={"fit_clearance_mm": 0.15})
assert moved.input_id != base.input_id
assert moved.build_id != base.build_id
def test_author_is_not_a_parameter():
"""
The mechanism behind the claim, asserted directly.
The ids are computed from the resolved parameter set, so the guarantee is
structural: if ``author`` never enters ``params``, it cannot reach a hash
however the hashing is later rewritten.
"""
rec = make(author=EMAIL)
assert "author" not in rec.params
assert EMAIL not in rec.input_canonical
assert EMAIL not in rec.build_canonical
# ---------------------------------------------------------------------------
# What the rendered line says
# ---------------------------------------------------------------------------
def test_rendered_line_carries_its_own_limit():
text = make(author=EMAIL).render()
line = [ln for ln in text.splitlines() if ln.startswith("author ")]
assert len(line) == 1
assert EMAIL in line[0]
assert "self-declared, unverified" in line[0]
def test_absent_author_renders_no_line_at_all():
text = make().render()
assert not [ln for ln in text.splitlines() if ln.startswith("author ")]
def test_empty_and_whitespace_are_absent_not_empty():
for value in ("", " ", None):
assert author_from(value) is None
assert not [ln for ln in make(author=value).render().splitlines()
if ln.startswith("author ")]
def test_a_verified_author_says_so():
"""
The positive control for the downgrade test below: the qualifier really
does change, so a parser that always reported "unverified" would be
distinguishable from one that read the text.
"""
author = Author(email=EMAIL, verified=True, method="OIDC, 2026-09-11")
line = author.describe()
assert "verified: OIDC, 2026-09-11" in line
assert "self-declared" not in line
def test_handle_decorates_but_does_not_replace_the_address():
author = Author(email=EMAIL, handle="TheRON")
described = author.describe()
assert described.startswith(EMAIL)
assert "TheRON" in described
# ---------------------------------------------------------------------------
# Reading a record back
# ---------------------------------------------------------------------------
def test_parse_recovers_the_address():
back = DesignRecord.parse(make(author=EMAIL).render())
assert back.author is not None
assert back.author.email == EMAIL
def test_parse_recovers_the_address_when_a_handle_is_present():
rec = make(author=Author(email=EMAIL, handle="TheRON"))
back = DesignRecord.parse(rec.render())
assert back.author.email == EMAIL
def test_parse_never_upgrades_a_claim():
"""
A verified record read back is self-declared, and that asymmetry is the
point: a text file cannot attest to its own verification, so the parser
trusts the address and not the adjective.
"""
rec = make(author=Author(email=EMAIL, verified=True, method="OIDC"))
assert "verified: OIDC" in rec.render() # it really was in the text
back = DesignRecord.parse(rec.render())
assert back.author.verified is False
def test_reading_and_rewriting_does_not_strip_attribution():
once = make(author=EMAIL).render()
twice = DesignRecord.parse(once).render()
assert EMAIL in twice
def test_parse_of_a_record_with_no_author_yields_none():
back = DesignRecord.parse(make().render())
assert back.author is None
def test_parse_tolerates_a_record_written_before_the_field_existed():
"""
Old records have no author line and must still read. The format version is
unchanged because nothing that identifies a design changed.
"""
text = make().render()
assert "MECHCOMP DESIGN RECORD 1" in text
back = DesignRecord.parse(text)
assert back.family == "3x" and back.profile == "Y"
assert back.author is None
# ---------------------------------------------------------------------------
# Accepting what a caller has
# ---------------------------------------------------------------------------
def test_author_from_accepts_a_bare_address_or_an_author():
assert author_from(EMAIL) == Author(email=EMAIL)
explicit = Author(email=EMAIL, verified=True, method="OIDC")
assert author_from(explicit) is explicit
def test_a_bare_address_is_never_verified():
assert author_from(EMAIL).verified is False
# ---------------------------------------------------------------------------
# Through the real build path
# ---------------------------------------------------------------------------
def test_build_carries_the_author_and_moves_no_id():
"""
The same claim again, through ``build()`` rather than ``record_for``, so a
wiring mistake between the two cannot hide behind a passing unit test.
"""
profiles = pytest.importorskip("mechcomp.profiles")
plain = profiles.build("3x", "Y")
signed = profiles.build("3x", "Y", author=EMAIL)
assert signed.record.author.email == EMAIL
assert plain.record.author is None
assert signed.record.input_id == plain.record.input_id
assert signed.record.build_id == plain.record.build_id
assert signed.report == plain.report