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
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.
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
@@ -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
# ---------------------------------------------------------------------------
@@ -151,6 +229,11 @@ class DesignRecord:
and the same record remade tomorrow describe the same part, and if the
timestamp entered the identity then regenerating a design would always
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
@@ -167,6 +250,7 @@ class DesignRecord:
verification: Dict[str, object] = field(default_factory=dict)
generated: str = ""
note: str = ""
author: Optional[Author] = None
# -- identity ----------------------------------------------------------
@@ -222,6 +306,8 @@ class DesignRecord:
"family %s" % self.family,
"profile %s" % self.profile,
]
if self.author is not None and self.author.present:
lines.append("author %s" % self.author.describe())
if self.generated:
lines.append("generated %s (excluded from both ids)" % self.generated)
if self.note:
@@ -264,11 +350,24 @@ class DesignRecord:
Only the fields that reproduce a design are recovered -- family, profile,
parameters, build identity. Verification values are outputs and are
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 = ""
code_revision = generator_revision = "unknown"
params: Dict[str, object] = {}
toolchain: Dict[str, str] = {}
author: Optional[Author] = None
section = ""
for raw in text.splitlines():
@@ -298,12 +397,21 @@ class DesignRecord:
family = line.split(None, 1)[1].strip()
elif line.startswith("profile "):
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(
family=family, profile=profile, params=params,
code_revision=code_revision,
generator_revision=generator_revision,
toolchain=toolchain,
author=author,
)
@@ -384,6 +492,23 @@ def toolchain_versions() -> Dict[str, str]:
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,
defaults: Mapping[str, object],
params: Optional[Mapping[str, object]] = None,
@@ -393,12 +518,17 @@ def record_for(family: str, profile: str,
code_revision: str = "unknown",
generator_revision: str = "unknown",
generated: str = "",
note: str = "") -> DesignRecord:
note: str = "",
author: object = None) -> DesignRecord:
"""
Build a record for one design.
``defaults`` comes from the family (``three_x.FAMILY.defaults``), ``params``
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] = {}
if report:
@@ -416,4 +546,5 @@ def record_for(family: str, profile: str,
verification=verification,
generated=generated,
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,
which is why those defaults are part of the port rather than part of the test
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
@@ -32,7 +38,8 @@ FAMILIES: Dict[str, Family] = {
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.
@@ -41,6 +48,9 @@ def build(family: str, profile: str,
does. An unknown *family* is a caller error rather than a rejection, so it
raises ``ValueError``: silently rejecting it would make a missing generator
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)
if fam is None:
@@ -48,4 +58,4 @@ def build(family: str, profile: str,
"unknown family %r; this port implements %s"
% (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
# that alters emitted geometry must bump it. See geom/report.py for what is
# 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"
Params = Mapping[str, object]
@@ -57,7 +62,7 @@ class Family:
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.
@@ -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
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
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.
@@ -95,6 +105,7 @@ def _design_record(family: Family, profile_type: str, params: Params,
fit=[fit.describe()],
code_revision=code_revision(),
generator_revision=REVISION,
author=author,
)
@@ -138,7 +149,8 @@ def rect_outline(w: float, h: float) -> List[tuple]:
# 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 {})}
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,
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]:
+28 -3
View File
@@ -14,6 +14,12 @@ WHAT IT SHOWS
what you are looking at is visible while you tune, not discovered
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
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
@@ -95,7 +101,8 @@ def profile_params(family, profile: str) -> List[Tuple[str, List[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
family = families()[family_name]
@@ -126,7 +133,8 @@ def build_payload(family_name: str, profile: str,
}
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:
payload["ok"] = False
payload["message"] = str(exc)
@@ -170,6 +178,8 @@ PAGE = """<!doctype html>
input,select { font:inherit; font-size:12.5px; padding:4px 7px;
border:1px solid var(--line); border-radius:5px; background:#fff; width:100%; }
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;
padding:18px; display:inline-block; min-width:300px; min-height:200px; }
.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>profile</span><select id="profile"></select></label>
</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>
<div class="stage">
@@ -220,6 +237,8 @@ async function refresh(sendValues) {
const q = new URLSearchParams();
q.set("family", state.family);
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);
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"])
document.getElementById(id).onchange = applyToggles;
document.getElementById("author").onchange = () => refresh(true);
refresh(false);
</script></body></html>
"""
@@ -311,8 +332,12 @@ class Handler(BaseHTTPRequestHandler):
q = {k: v[0] for k, v in parse_qs(parsed.query).items()}
family = q.pop("family", "3x")
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:
payload = build_payload(family, profile, q)
payload = build_payload(family, profile, q, author)
except Exception as exc: # noqa: BLE001
payload = {"ok": False,
"message": "%s: %s" % (type(exc).__name__, exc),