diff --git a/src/mechcomp/design_record.py b/src/mechcomp/design_record.py index db303bf..184df1f 100644 --- a/src/mechcomp/design_record.py +++ b/src/mechcomp/design_record.py @@ -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), ) diff --git a/src/mechcomp/profiles/__init__.py b/src/mechcomp/profiles/__init__.py index dd1a8bf..366f5f0 100644 --- a/src/mechcomp/profiles/__init__.py +++ b/src/mechcomp/profiles/__init__.py @@ -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) diff --git a/src/mechcomp/profiles/_common.py b/src/mechcomp/profiles/_common.py index df8b669..6a11666 100644 --- a/src/mechcomp/profiles/_common.py +++ b/src/mechcomp/profiles/_common.py @@ -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]: diff --git a/src/mechcomp/web/app.py b/src/mechcomp/web/app.py index 5046864..744e491 100644 --- a/src/mechcomp/web/app.py +++ b/src/mechcomp/web/app.py @@ -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 = """ 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 = """ +
+ Author + +

Recorded with the design, never checked, and not stored + anywhere. The record says it is self-declared.

+
@@ -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); """ @@ -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), diff --git a/tests/test_authorship.py b/tests/test_authorship.py new file mode 100644 index 0000000..3acbda3 --- /dev/null +++ b/tests/test_authorship.py @@ -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