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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions docs/decisions/acquisition-identifiers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
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.
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 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

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 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.

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.
91 changes: 91 additions & 0 deletions test/acquisition-identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/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
import unicodedata

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
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')


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')
Loading