From d665170646f998a6351b28bc68de2780aea963e8 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 5 Oct 2026 03:25:47 -0700 Subject: [PATCH 1/3] docs: propose opaque acquisition identifier contract --- docs/decisions/acquisition-identifiers.md | 91 +++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/decisions/acquisition-identifiers.md diff --git a/docs/decisions/acquisition-identifiers.md b/docs/decisions/acquisition-identifiers.md new file mode 100644 index 0000000..0afbd67 --- /dev/null +++ b/docs/decisions/acquisition-identifiers.md @@ -0,0 +1,91 @@ +--- +schema: "git-locks-decision/1" +id: "acquisition-identifiers" +status: "proposed" +task: "GL-006" +issue: "https://github.com/git-stunts/locks/issues/74" +created: "2026-10-05" +baseline_commit: "6735e52007ab86a37feda8b43b1d43742fdb5f94" +--- + +# Acquisition identifiers + +## Proposed decision + +Keep acquisition identifiers opaque. An identifier must be a nonempty UTF-8 line without carriage return, line feed, or NUL. +Compare the complete identifier without normalization. Do not require the current generator's numeric pattern. + +An acquisition identifies one reservation lifetime. A record object ID identifies one stored version of that reservation. +Renewal can change the record object ID while the acquisition stays the same. +Callers must retain the returned acquisition value and pass it unchanged when they need an ownership guard. + +This proposal awaits the task's review and recorded acceptance. It does not complete issue #74 or GL-007. + +## Why + +The current public validators accept opaque identifiers. Stored lock and semaphore records use the same text rule. +Offline migration preserves existing record objects. It does not replace acquisition identifiers with newly generated values. +A new generator-pattern restriction could reject a store that the current validator accepts. +No migration rule or user requirement justifies that compatibility change. + +The current generator combines time, process ID, and random values. That format describes its implementation, not the caller's validation contract. +A syntactically valid value can still be stale. Pattern validation cannot establish ownership. + +These values are not passwords, authenticated principals, or fencing tokens. The tool coordinates cooperating callers with access to one authority. +This decision does not claim cryptographic randomness, collision impossibility, or protection from a writer that ignores the protocol. + +## Required behavior + +| Input or state | Required result | +| --- | --- | +| Supplied empty identifier, invalid UTF-8, carriage return, or line feed | Usage error; exit 2; no mutation. | +| NUL in a stored record | Reject the record through the byte-validating decoder. Process arguments cannot contain NUL. | +| Valid identifier that differs from an existing reservation | Release reports `nothing` with `superseded`, exit 0. Renewal refuses with `superseded`, exit 1. | +| Valid guard for an absent job or slot | Preserve the command's existing absence result. Absence does not establish that the guard was once valid. | +| Matching acquisition guard | Continue the command's other checks. A match alone does not permit an expired renewal. | +| Both `--record` and `--acquisition` supplied | Require each value to match its own field. Option order must not remove a guard. | +| Guard omitted | Preserve the documented unguarded operation. Do not imply that it proves caller ownership. | +| Invalid acquisition in stored authority | Ordinary operations fail closed. Doctor reports the invalid record. | + +“Unknown” means a well-formed value that does not match the current acquisition. It is not a separate syntax error. +For example, `stale-owner` is well formed. It cannot release a reservation whose acquisition differs. + +Path re-claim creates a new acquisition. Path renewal preserves the acquisition. +A live semaphore refresh by its holder preserves the acquisition. Re-acquisition after expiry creates a new one. +Family record changes preserve the parent's acquisition. A record guard can therefore become stale while its acquisition guard still matches. + +## Alternatives + +| Option | Consequence | +| --- | --- | +| Require the current numeric generator pattern | Couples readers to one implementation and rejects previously accepted identities without a migration plan. Reject this option. | +| Introduce a versioned identifier grammar | Can support future semantics, but requires a format decision, compatibility rules, and migration evidence. Defer this option. | +| Keep opaque identifiers with explicit text validation | Matches current readers and migration. Select this option, subject to review. | + +## Evidence and limits + +The source baseline is the commit in frontmatter. These references describe static source inspection at that revision. + +- [`valid_holder`](../../lib/030-time-refs-records.sh) checks a nonempty UTF-8 line without carriage return or line feed. +- [`validate_record`](../../lib/055-record-validation.sh) applies that rule to lock and semaphore acquisition fields. +- [`new_acquisition` and family rewrites](../../lib/080-families.sh) separate generated identity from later record versions. +- [Path release](../../lib/110-release.sh), [renewal](../../lib/140-extend.sh), and [semaphore operations](../../lib/170-semaphores.sh) define matching, stale, expiry, and absence results. +- [Offline migration](../../lib/185-migrate.sh) copies existing object references into the new state tree. +- [Release regressions](../../test/release-guards.py) cover malformed, stale, matching, omitted, and combined guards. +- [State coherence tests](../../test/state-coherence.py) check migration without object-identity changes. + +[PR #123](https://github.com/git-stunts/locks/pull/123) corrected semaphore guard aliasing at this baseline. +Its full guarded suite passed, including 31 release-guard cases. The four RED failures represented three distinct failure scenarios. +This evidence does not prove that every historical deployment uses the current generator pattern. +No new runtime experiment was performed for this document. A dedicated nonnumeric stored-identity migration case remains to be added during contract implementation. + +## Implementation boundary + +GL-007 must align the output schema, command reference, and event tables with this decision after acceptance. +It must express the text constraints without imposing the numeric generator pattern. +It must test nonnumeric stored identities through migration, renewal, and guarded release for both reservation types where supported. +It must preserve exact identity bytes and the independent record/acquisition comparisons introduced by PR #123. +It must distinguish malformed input, absent authority, stale identity, expired renewal, and damaged stored records. + +Changing the generator can remain compatible if readers continue to treat identities as opaque. +Any future restriction on accepted stored identities requires a separate compatibility decision and migration plan. From 512fd4af5f3446e1bb5074ffa4c2fb1bcc2facef Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 5 Oct 2026 03:28:00 -0700 Subject: [PATCH 2/3] test: verify opaque identities through migration and renewal --- Makefile | 1 + docs/decisions/acquisition-identifiers.md | 8 ++- test/acquisition-identity.py | 85 +++++++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 test/acquisition-identity.py diff --git a/Makefile b/Makefile index fb071f6..35f2bab 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,7 @@ test-container: python3 test/doctor-findings.py python3 test/renewal.py python3 test/release-guards.py + python3 test/acquisition-identity.py python3 test/wrapper-lifecycle.py bash test/test.sh python3 test/capacity.py diff --git a/docs/decisions/acquisition-identifiers.md b/docs/decisions/acquisition-identifiers.md index 0afbd67..bdb1f5a 100644 --- a/docs/decisions/acquisition-identifiers.md +++ b/docs/decisions/acquisition-identifiers.md @@ -77,13 +77,17 @@ The source baseline is the commit in frontmatter. These references describe stat [PR #123](https://github.com/git-stunts/locks/pull/123) corrected semaphore guard aliasing at this baseline. Its full guarded suite passed, including 31 release-guard cases. The four RED failures represented three distinct failure scenarios. This evidence does not prove that every historical deployment uses the current generator pattern. -No new runtime experiment was performed for this document. A dedicated nonnumeric stored-identity migration case remains to be added during contract implementation. +The guarded `test/acquisition-identity.py` run passed four synthetic compatibility cases on 2026-10-05. +It tested path locks and semaphore slots with nonnumeric ASCII and Unicode identifiers. +Each case preserved the complete root through offline migration, preserved identity through a record rewrite, rejected stale guards, and released with the matching acquisition. +The fixtures include a combining character and retain its exact representation. They do not establish the contents of real historical stores. ## Implementation boundary GL-007 must align the output schema, command reference, and event tables with this decision after acceptance. It must express the text constraints without imposing the numeric generator pattern. -It must test nonnumeric stored identities through migration, renewal, and guarded release for both reservation types where supported. +It must retain the nonnumeric stored-identity tests through migration, renewal, and guarded release for both reservation types. +It must add any missing cases required by schema or event-contract changes. It must preserve exact identity bytes and the independent record/acquisition comparisons introduced by PR #123. It must distinguish malformed input, absent authority, stale identity, expired renewal, and damaged stored records. diff --git a/test/acquisition-identity.py b/test/acquisition-identity.py new file mode 100644 index 0000000..627d5ae --- /dev/null +++ b/test/acquisition-identity.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Synthetic opaque identities survive offline migration and record rewrites.""" +from pathlib import Path +import subprocess + +ROOT = Path(__file__).resolve().parents[1] +subprocess.run(['node', str(ROOT / 'scripts/require-docker.mjs')], check=True) + +import json +import os +import tempfile + +BASE = {key: value for key, value in os.environ.items() if not key.startswith('GIT_')} + + +def exercise(directory, kind, identity): + store = directory / 'store.git' + env = dict(BASE, GIT_LOCKS_STORE=str(store), GIT_LOCKS_TEST_HOOKS='1', GIT_LOCKS_NOW='1000000') + git_env = dict(BASE, GIT_INDEX_FILE=str(directory / 'index')) + + def git(*args, data=None): + return subprocess.check_output(['git', '--git-dir=' + str(store), *args], + input=data, env=git_env, timeout=10) + + def locks(*args): + result = subprocess.run([str(ROOT / 'bin/git-locks'), *args], env=env, + capture_output=True, text=True, timeout=10) + assert result.returncode == 0 and not result.stderr, (args, result) + return [json.loads(line) for line in result.stdout.splitlines()] + + def root(): + return git('rev-parse', 'refs/locks/state').decode().strip() + + if kind == 'path': + receipt = locks('claim', '--job', 'owner', '--holder', 'alice', 'held')[0] + release = ['release', '--job', 'owner'] + else: + locks('sem', 'create', 'gpu', '--capacity', '1') + receipt = locks('sem', 'acquire', 'gpu', '--job', 'owner', '--holder', 'alice')[0] + release = ['sem', 'release', 'gpu', '--job', 'owner'] + before = root() + original = git('cat-file', 'blob', receipt['record']) + marker = ('acquisition: ' + receipt['acquisition']).encode() + assert original.count(marker) == 1 + rewritten = original.replace(marker, ('acquisition: ' + identity).encode('utf-8')) + record = git('hash-object', '-w', '--stdin', data=rewritten).decode().strip() + git('read-tree', before) + for entry in git('ls-tree', '-r', before).decode().splitlines(): + metadata, name = entry.split('\t') + if metadata.split()[2] == receipt['record']: + git('update-index', '--cacheinfo', '100644', record, name) + synthetic = git('write-tree').decode().strip() + git('update-ref', 'refs/locks/state', synthetic, before) + locks('doctor') + + # Convert only this stopped, isolated fixture to the legacy ref layout. + for entry in git('ls-tree', '-r', synthetic).decode().splitlines(): + metadata, name = entry.split('\t') + git('update-ref', 'refs/locks/' + name, metadata.split()[2]) + git('update-ref', '-d', 'refs/locks/state', synthetic) + locks('migrate', '--offline') + assert root() == synthetic, 'migration changed opaque identity or any other object' + if kind == 'path': + locks('extend', '--job', 'owner', '--acquisition', identity, '--ttl', '600') + current = locks('show', '--job', 'owner')[0] + else: + current = locks('sem', 'acquire', 'gpu', '--job', 'owner', '--holder', 'alice', '--ttl', '600')[0] + assert current['acquisition'] == identity + assert current['record'] != record, 'renewal fixture must change the record version' + renewed = root() + stale = locks(*release, '--record', record, '--acquisition', identity)[0] + assert stale['reason'] == 'superseded' and root() == renewed + stale = locks(*release, '--acquisition', identity + '-other')[0] + assert stale['reason'] == 'superseded' and root() == renewed + assert locks(*release, '--acquisition', identity)[0]['event'] == 'released' + assert root() != renewed + locks('doctor') + + +for kind in ('path', 'semaphore'): + for identity in ('opaque-legacy-owner', 'owner-e\u0301-雪'): + with tempfile.TemporaryDirectory(prefix='locks-opaque-identity-') as temporary: + exercise(Path(temporary), kind, identity) + print('PASS', kind, repr(identity), flush=True) +print('opaque acquisition identity: 4 migration/rewrite/release cases passed') From 4bce816f19a3ee836b89bc34726cc4b6d6c20334 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 5 Oct 2026 03:31:54 -0700 Subject: [PATCH 3/3] test: reject normalized aliases of acquisition guards --- docs/decisions/acquisition-identifiers.md | 2 +- test/acquisition-identity.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/decisions/acquisition-identifiers.md b/docs/decisions/acquisition-identifiers.md index bdb1f5a..6a4e681 100644 --- a/docs/decisions/acquisition-identifiers.md +++ b/docs/decisions/acquisition-identifiers.md @@ -80,7 +80,7 @@ This evidence does not prove that every historical deployment uses the current g The guarded `test/acquisition-identity.py` run passed four synthetic compatibility cases on 2026-10-05. It tested path locks and semaphore slots with nonnumeric ASCII and Unicode identifiers. Each case preserved the complete root through offline migration, preserved identity through a record rewrite, rejected stale guards, and released with the matching acquisition. -The fixtures include a combining character and retain its exact representation. They do not establish the contents of real historical stores. +The Unicode fixtures include a combining character. The tests also require rejection of its distinct NFC-normalized representation. They do not establish the contents of real historical stores. ## Implementation boundary diff --git a/test/acquisition-identity.py b/test/acquisition-identity.py index 627d5ae..e9ce9b6 100644 --- a/test/acquisition-identity.py +++ b/test/acquisition-identity.py @@ -9,6 +9,7 @@ import json import os import tempfile +import unicodedata BASE = {key: value for key, value in os.environ.items() if not key.startswith('GIT_')} @@ -70,8 +71,13 @@ def root(): renewed = root() stale = locks(*release, '--record', record, '--acquisition', identity)[0] assert stale['reason'] == 'superseded' and root() == renewed - stale = locks(*release, '--acquisition', identity + '-other')[0] - assert stale['reason'] == 'superseded' and root() == renewed + mismatches = [identity + '-other'] + normalized = unicodedata.normalize('NFC', identity) + if normalized != identity: + mismatches.append(normalized) + for mismatch in mismatches: + stale = locks(*release, '--acquisition', mismatch)[0] + assert stale['reason'] == 'superseded' and root() == renewed assert locks(*release, '--acquisition', identity)[0]['event'] == 'released' assert root() != renewed locks('doctor')